
В предыдущем обучающем посте мы уже разобрали, как создавать сигнальные торговые боты. В этом материале сделаем следующий шаг и рассмотрим, как настроить визуальный инструмент для Spectra Charts, который будет рисовать любые прямые и наклонные линии поверх графика.
Такой инструмент особенно полезен тем, кто торгует на бинарных опционах и платформе Pocket Option, но не хочет вручную чертить каждый уровень и трендовую линию. Наша задача — обучить нейросеть помогать вам в этой рутинной работе и создавать наглядные свечные графики с линиями и зонами, которые упрощают анализ.
По мере того как вы будете осваивать создание разных видов инструментов, вы сможете обучать нейросеть:
Все зависит от вашего опыта, желаний и воображения. Главное — правильно подготовить промпты и каркасы кода.
Аналогично созданию сигнальных ботов, начинать нужно с обучения чата. Только в этой статье мы делаем акцент не на логике сделок, а на рисовании поверх графика.
Чтобы обучение нейросети прошло корректно, важно соблюдать несколько простых правил.
Текст промпта, который вы даёте GPT перед вставкой первого кода:
- Я тебе даю код, тебе нужно изучить его и понять, как правильно рисовать поверх графика линии и делать заливки между линиями,
- Так же изучи структуру кода для правильного написания индикаторов и ботов,
- Когда будешь писать мне исправления кода, обновления, вносить мелкие правки, всегда отдавай готовый код полностью,
- В ответ скажи мне что все понял и жди от меня дальнейших команд,
- Настройки в параметрах всегда прописывай на моем языке понятными словами
Сам код:
// ======================== 📊 Trend Signals — EMA Ribbon (price-cross recolor + clean band fill + MACD/ADX arrows) ========================
export const meta = {
name: "Trend Signals v1.2",
defaultParams: {
// ===== EMA =====
maShortPeriod: 20,
maLongPeriod: 50,
// Меняем цвет ТОЛЬКО при реальном кроссе и удерживаем до следующего
minFlipBars: 1, // анти-дребезг: минимум баров между переключениями цвета
// ===== Заливка между EMA =====
showBand: true,
bandFillColor: "#6b7280", // серый fill (из исходника)
bandOpacity: 0.35,
// ===== Цвета EMA (обе EMA — одинаковые) =====
shortUpColor: "#22c55e",
shortDownColor:"#ef4444",
longUpColor: "#22c55e",
longDownColor: "#ef4444",
lineWidthShort: 2,
lineWidthLong: 3,
// ===== MACD (для стрелок) =====
fastLength: 12,
slowLength: 26,
signalLength: 9,
// ===== ADX/DI (фильтр силы) =====
diLen: 14,
adxLen: 14,
adxThreshold: 25,
// ===== Маркеры =====
showBuySell: false, // включи true, если хочешь видеть стрелки по умолчанию
buyText: "BUY",
sellText:"SELL",
buyColor:"#16a34a",
sellColor:"#ef4444",
buyTextColor:"#111827",
sellTextColor:"#111827",
},
paramMeta: {
maShortPeriod: { label:"Короткая EMA", type:"number", min:2, max:200 },
maLongPeriod: { label:"Длинная EMA", type:"number", min:5, max:400 },
minFlipBars: { label:"Мин. баров между перекрасами", type:"number", min:0, max:10 },
showBand: { label:"Показывать заливку", type:"boolean" },
bandFillColor: { label:"Цвет заливки", type:"color" },
bandOpacity: { label:"Прозрачность заливки", type:"number", min:0, max:1, step:0.05 },
shortUpColor: { label:"Короткая: цена выше", type:"color" },
shortDownColor: { label:"Короткая: цена ниже", type:"color" },
longUpColor: { label:"Длинная: цена выше", type:"color" },
longDownColor: { label:"Длинная: цена ниже", type:"color" },
lineWidthShort: { label:"Толщина короткой", type:"number", min:1, max:6 },
lineWidthLong: { label:"Толщина длинной", type:"number", min:1, max:6 },
fastLength: { label:"MACD быстрая", type:"number", min:2, max:100 },
slowLength: { label:"MACD медленная", type:"number", min:5, max:300 },
signalLength: { label:"MACD сигнал", type:"number", min:2, max:100 },
diLen: { label:"DI период", type:"number", min:2, max:100 },
adxLen: { label:"ADX период", type:"number", min:2, max:100 },
adxThreshold: { label:"Порог ADX", type:"number", min:5, max:60 },
showBuySell: { label:"Показывать BUY/SELL", type:"boolean" },
buyText: { label:"Текст BUY", type:"text" },
sellText: { label:"Текст SELL", type:"text" },
buyColor: { label:"Цвет BUY", type:"color" },
sellColor: { label:"Цвет SELL", type:"color" },
buyTextColor: { label:"Цвет текста BUY", type:"color" },
sellTextColor: { label:"Цвет текста SELL", type:"color" },
}
};
export function init(ctx) {
const { chart, params, LineSeries, candleSeries, createSeriesMarkers } = ctx;
// ===== математика =====
function ema(src, len){
const n=src.length; if(!n) return [];
const out=new Array(n).fill(null); const a=2/(len+1);
out[0]=src[0]; for(let i=1;i<n;i++) out[i]=a*src[i]+(1-a)*out[i-1];
return out;
}
function rma(src, len){
const n=src.length; if(!n) return [];
const out=new Array(n).fill(null);
out[0]=src[0]; for(let i=1;i<n;i++) out[i]=(out[i-1]*(len-1)+src[i])/len;
return out;
}
function hexToRgba(hex, a){
const h = hex.replace("#",""), n = parseInt(h.length===3?h.split("").map(c=>c+c).join(""):h,16);
const r=(n>>16)&255, g=(n>>8)&255, b=n&255; return `rgba(${r},${g},${b},${a})`;
}
// устойчивое состояние «цена над/под EMA» — переключаем ТОЛЬКО при реальном кроссе
function crossState(close, ema, minFlipBars){
const n=close.length, state=new Array(n).fill(null);
let i0=0; while(i0<n && !(Number.isFinite(close[i0]) && Number.isFinite(ema[i0]))) i0++;
if (i0>=n) return state;
state[i0] = close[i0] >= ema[i0];
let lastFlip=i0;
for (let i=i0+1;i<n;i++){
if (!Number.isFinite(close[i]) || !Number.isFinite(ema[i])) { state[i]=state[i-1]; continue; }
const prev=close[i-1]-ema[i-1], curr=close[i]-ema[i];
let s=state[i-1];
const crossUp = prev <= 0 && curr > 0;
const crossDown = prev >= 0 && curr < 0;
if (crossUp && (i-lastFlip)>=minFlipBars){ s=true; lastFlip=i; }
if (crossDown && (i-lastFlip)>=minFlipBars){ s=false; lastFlip=i; }
state[i]=s;
}
return state;
}
// сегментация линий (каждый участок цвета — отдельная серия)
function segmentsFromState(times, values, stateBool){
const upSegs=[], downSegs=[];
let curr=null;
for (let i=0;i<values.length;i++){
const v = values[i], st = stateBool[i];
if (!Number.isFinite(v) || st==null){ curr=null; continue; }
if (!curr || curr.type!==st){
curr = { arr: [], type: st };
(st ? upSegs : downSegs).push(curr.arr);
if (i>0 && Number.isFinite(values[i-1]) && stateBool[i-1]!=null && stateBool[i-1]!==st){
curr.arr.push({ time: times[i-1], value: values[i-1] });
}
}
curr.arr.push({ time: times[i], value: v });
}
return { upSegs, downSegs };
}
// менеджер серий-сегментов
const segSeries = { shortUp:[], shortDown:[], longUp:[], longDown:[] };
function ensureSegSeries(bucket, idx, color, width){
let s = bucket[idx];
if (!s){
s = chart.addSeries(LineSeries, {
color,
lineWidth: Math.max(1, width|0 || 2),
lastValueVisible:false,
priceLineVisible:false,
crosshairMarkerVisible:false,
});
bucket[idx] = s;
} else {
s.applyOptions?.({ color, lineWidth: Math.max(1, width|0 || 2) });
}
return s;
}
function syncSegments(key, color, width, segs){
const bucket = segSeries[key];
for (let i=0;i<segs.length;i++){
const s = ensureSegSeries(bucket, i, color, width);
s.setData(segs[i]);
}
for (let i=segs.length; i<bucket.length; i++){
const s=bucket[i]; if (s) chart.removeSeries(s);
}
bucket.length = segs.length;
}
function clearAllSegs(){
for (const key of Object.keys(segSeries)){
for (const s of segSeries[key]) if (s) chart.removeSeries(s);
segSeries[key] = [];
}
}
// ===== заливка между EMA (полигон без диагоналей, разбитый на участки между пересечениями EMA) =====
class BandPrimitive {
constructor(o={}){ this._v = new BandView(o); }
attached(p){ this._v.attached(p); }
detached(){ this._v.detached(); }
paneViews(){ return [this._v]; }
updateAllViews(){ this._v.update(); }
setOptions(o){ this._v.setOptions(o); }
setBand(times, upper, lower, whichUpper){ this._v.setBand(times, upper, lower, whichUpper); }
}
class BandView {
constructor(o){
this._s=null; this._c=null; this._req=null;
this._t=[]; this._u=[]; this._l=[]; this._w=[];
this._o={ fill:"#6b7280", alpha:0.35, ...o };
}
attached({series,chart,requestUpdate}){ this._s=series; this._c=chart; this._req=requestUpdate; }
detached(){ this._s=this._c=this._req=null; }
update(){}
zOrder(){ return "bottom"; }
setOptions(o){ Object.assign(this._o,o); this._req?.(); }
setBand(t,u,l,w){ this._t=t||[]; this._u=u||[]; this._l=l||[]; this._w=w||[]; this._req?.(); }
renderer(){
const self=this;
const drawImpl=(target)=>{
const s=self._s,c=self._c; if(!s||!c) return;
const ts=c.timeScale(); if(!ts) return;
const o=self._o;
const draw=(ctx,prX=1,prY=1)=>{
ctx.save(); ctx.scale(prX,prY);
ctx.globalAlpha=Math.max(0,Math.min(1,o.alpha));
ctx.fillStyle=o.fill;
const N=self._t.length;
let segU=[], segL=[], prev=null;
function flush(){
if (segU.length<2){ segU=[]; segL=[]; return; }
ctx.beginPath();
ctx.moveTo(segU[0].x, segU[0].y);
for (let i=1;i<segU.length;i++) ctx.lineTo(segU[i].x, segU[i].y);
for (let i=segL.length-1;i>=0;i--) ctx.lineTo(segL[i].x, segL[i].y);
ctx.closePath(); ctx.fill();
segU=[]; segL=[];
}
for (let i=0;i<N;i++){
const t=self._t[i], uV=self._u[i], lV=self._l[i], w=self._w[i];
if (!Number.isFinite(uV)||!Number.isFinite(lV)||w==null){ flush(); prev=null; continue; }
const x=ts.timeToCoordinate(t), yU=s.priceToCoordinate(uV), yL=s.priceToCoordinate(lV);
if (x==null||yU==null||yL==null){ flush(); prev=null; continue; }
if (prev===null) prev=w;
if (w!==prev){ flush(); prev=w; }
segU.push({x, y:yU});
segL.push({x, y:yL});
}
flush();
ctx.restore();
};
if (typeof target.useBitmapCoordinateSpace==="function"){
target.useBitmapCoordinateSpace(sc=>draw(sc.context, sc.horizontalPixelRatio, sc.verticalPixelRatio));
} else {
draw(target.context, target.pixelRatio||1, target.pixelRatio||1);
}
};
return { drawBackground: drawImpl, draw: drawImpl };
}
}
let bandPrim = new BandPrimitive({
fill: hexToRgba(params.bandFillColor, params.bandOpacity ?? 0.35),
alpha: params.bandOpacity ?? 0.35
});
if (typeof candleSeries.attachPrimitive === "function") {
candleSeries.attachPrimitive(bandPrim);
} else if (ctx.primitivesApi?.attachPrimitive) {
ctx.primitivesApi.attachPrimitive(candleSeries, bandPrim);
} else {
bandPrim = null; // нет primitives — без заливки
}
// ===== контроллер маркеров: пытаемся через оба API =====
const markerCtrl = (() => {
let api = null;
try {
api = (typeof createSeriesMarkers === "function")
? createSeriesMarkers(candleSeries, [])
: null;
} catch(_) {}
return {
set(list){ try { api?.setMarkers?.(list); } catch(_) {} try { candleSeries.setMarkers?.(list); } catch(_) {} },
clear(){ try { api?.setMarkers?.([]); } catch(_) {} try { candleSeries.setMarkers?.([]); } catch(_) {} },
};
})();
// быстрый хук на смену параметров (если рантайм его вызывает)
function onParamsChanged(next){
if (!next.showBuySell) markerCtrl.clear(); // мгновенно скрыть стрелки
}
function update(candles){
const N=candles?.length||0;
if (N<5){
clearAllSegs();
bandPrim?.setBand([],[],[],[]);
if (!params.showBuySell) markerCtrl.clear();
return [];
}
const times=candles.map(c=>c.time);
const high =candles.map(c=>c.high);
const low =candles.map(c=>c.low);
const close=candles.map(c=>c.close);
// ===== EMA
const sLen=Math.max(2, params.maShortPeriod|0 || 20);
const lLen=Math.max(3, params.maLongPeriod|0 || 50);
const emaS=ema(close, sLen);
const emaL=ema(close, lLen);
// ===== устойчивые состояния (перекраска только при кроссах)
const minFlip = Math.max(0, params.minFlipBars|0 || 1);
const stateS = crossState(close, emaS, minFlip);
const stateL = crossState(close, emaL, minFlip);
// ===== сегменты короткой/длинной EMA
const segS = segmentsFromState(times, emaS, stateS);
const segL = segmentsFromState(times, emaL, stateL);
syncSegments("shortUp", params.shortUpColor, params.lineWidthShort, segS.upSegs);
syncSegments("shortDown", params.shortDownColor, params.lineWidthShort, segS.downSegs);
syncSegments("longUp", params.longUpColor, params.lineWidthLong, segL.upSegs);
syncSegments("longDown", params.longDownColor, params.lineWidthLong, segL.downSegs);
// ===== заливка между EMA
if (bandPrim && params.showBand){
const upper = emaS.map((v,i)=> Math.max(v ?? NaN, emaL[i] ?? NaN));
const lower = emaS.map((v,i)=> Math.min(v ?? NaN, emaL[i] ?? NaN));
const whichUpper = emaS.map((v,i)=> (Number.isFinite(v) && Number.isFinite(emaL[i])) ? v>=emaL[i] : null);
bandPrim.setOptions({
fill: hexToRgba(params.bandFillColor, params.bandOpacity ?? 0.35),
alpha: params.bandOpacity ?? 0.35
});
bandPrim.setBand(times, upper, lower, whichUpper);
} else if (bandPrim){
bandPrim.setBand([],[],[],[]);
}
// ===== стрелки BUY/SELL (MACD + ADX)
if (params.showBuySell){
const fastMA=ema(close, Math.max(2, params.fastLength|0 || 12));
const slowMA=ema(close, Math.max(3, params.slowLength|0 || 26));
const macd =fastMA.map((v,i)=> (v ?? 0) - (slowMA[i] ?? 0));
const signal=ema(macd, Math.max(2, params.signalLength|0 || 9));
const tr=new Array(N).fill(0);
for (let i=1;i<N;i++){
const v1=high[i]-low[i], v2=Math.abs(high[i]-close[i-1]), v3=Math.abs(low[i]-close[i-1]);
tr[i]=Math.max(v1,v2,v3);
}
const plusDM=new Array(N).fill(0), minusDM=new Array(N).fill(0);
for (let i=1;i<N;i++){
const d=close[i]-close[i-1];
plusDM[i]=(d>0 && d>-d)? d:0;
minusDM[i]=(-d>0 && -d>d)? -d:0;
}
const diLen = Math.max(2, params.diLen|0 || 14);
const adxLen= Math.max(2, params.adxLen|0 || 14);
const atrR = rma(tr, diLen);
const plusR = rma(plusDM, diLen);
const minusR= rma(minusDM, diLen);
const plusDI = plusR.map((v,i)=> 100 * (v ?? 0) / ((atrR[i] ?? 0) || 1));
const minusDI= minusR.map((v,i)=> 100 * (v ?? 0) / ((atrR[i] ?? 0) || 1));
const sumDI = plusDI.map((_,i)=> (plusDI[i] + minusDI[i]) || 1);
const diff = plusDI.map((_,i)=> Math.abs(plusDI[i]-minusDI[i]) / sumDI[i]);
const adxArr = rma(diff, adxLen).map(v=> 100*(v ?? 0));
const thr = Number(params.adxThreshold ?? 25);
const markers = [];
for (let i=1;i<N;i++){
const crossUp = macd[i] > signal[i] && macd[i-1] <= signal[i-1] && macd[i-1] < 0;
const crossDown = signal[i] > macd[i] && signal[i-1] <= macd[i-1] && macd[i-1] > 0;
if (adxArr[i] > thr && crossUp){
markers.push({ name:"buy", time:times[i], position:"belowBar", shape:"arrowUp",
color:params.buyColor, price:low[i], text:params.buyText, textColor:params.buyTextColor });
}
if (adxArr[i] > thr && crossDown){
markers.push({ name:"sell", time:times[i], position:"aboveBar", shape:"arrowDown",
color:params.sellColor, price:high[i], text:params.sellText, textColor:params.sellTextColor });
}
}
markerCtrl.set(markers);
} else {
markerCtrl.clear();
}
return [];
}
function destroy(){
clearAllSegs();
try{
bandPrim?.setBand([],[],[],[]);
if (typeof candleSeries.detachPrimitive === "function") candleSeries.detachPrimitive(bandPrim);
else if (typeof candleSeries.removePrimitive === "function") candleSeries.removePrimitive(bandPrim);
}catch(_){}
markerCtrl.clear();
}
return { update, destroy, onParamsChanged };
}
После первого обучающего кода вам нужно отправить второй блок, который отвечает за построение горизонтальных уровней и зон. На этом шаге нейросеть учится рисовать:
Текст промпта для второго кода:
- Вот тебе второй код, научись как правильно рисовать горизонтальные линии и горизонтальные блоки с заливкой,
- Просто изучи код и скажи что все понял и принял, после чего жди от меня команду с моими пожеланиями для создания новых инструментов
Сам код:
export const meta = {
name: "Support & Resistance Signals",
defaultParams: {
// ======= 🎯 СИГНАЛЫ =======
signalsEnabled: true, // 🔕 Сигналы: Включены/Выключены
mode: "Отскок", // "Отскок" | "Пробой" | "Ретест"
signalFilter: "Простые", // "Простые" | "С фильтрами"
bufferAtr: 0.20, // ×ATR — буфер для событий/подтверждений
// — Поведение сигналов —
minLevelAgeBars: 1, // Возраст уровня (бары) до первого сигнала
firstTouchOnly: true, // Только первое касание
reentryAwayAtr: 0.00, // Повторный заход: отход ≥ ×ATR (0=выкл)
confirmOverMid: false, // Для «Простых»: требовать закрытие за серединой зоны
confirmBufferAtr: 0.15, // Буфер по середине (×ATR)
// ======= 🧱 УРОВНИ =======
method: "По теням", // "По теням" | "По телам"
leftBars: 5, // L — пивот слева
rightBars: 3, // R — подтверждение справа
// Допуск равенства для пивотов (почти равные High/Low не ломают подтверждение)
eqTolMode: "×ATR", // "×ATR" | "Пункты"
eqTolAtr: 0.04, // ×ATR (если режим ×ATR)
eqTolPoints: 0, // пункты (если режим Пункты)
thicknessMode: "Авто", // "Авто" | "Пункты"
zoneAtrMult: 0.30, // ×ATR (толщина авто)
thicknessPoints: 0, // пункты (для «Пункты»)
zoneMinPoints: 0, // мин. толщина (пункты)
mergeEnabled: true, // сливать близкие уровни
// ======= ⛔ СОБЫТИЯ/ПРОДЛЕНИЕ =======
keepUntilBreak: true, // 📌 Не удалять до пробоя (держать после касаний)
stopOn: "Оба варианта", // если keepUntilBreak=false: "Касание" | "Пробой" | "Оба варианта"
expiryBars: 3, // экспирация «сырых» зон (когда keepUntilBreak=false)
preSignals: true, // предсигналы (логика до закрытия)
// ======= ⚙️ ПРОФИ =======
lookbackBars: 1000,
atrPeriod: 14,
minImpulseAtr: 0.0, // мин. импульс после свинга (×ATR), 0=выкл
mergeTolAtr: 0.25,
mergeTimeGap: 10,
touchBy: "Тени", // "Тени" | "Закрытию"
breakBy: "Закрытию", // "Закрытию" | "Тени"
minVolAtr: 0.0,
cooldownBars: 2,
minDistanceOppAtr: 0.7,
maxLevels: 40,
maxLevelsPerSide: 30,
// ======= 🎨 ВИД ЗОН =======
fillAlpha: 0.10,
showBorder: false,
borderWidth: 1,
supColor: "#10b981", // поддержка — зелёный
resColor: "#f43f5e", // сопротивление — красный
mitigatedColor: "#3b82f6", // тестированная — синий
invalidColor: "#9ca3af", // сломанная — серый
// ======= 🏷 МАРКЕРЫ =======
buyText: "BUY",
sellText: "SELL",
buyColor: "#16a34a",
sellColor: "#ef4444",
buyTextColor: "#ffffff",
sellTextColor: "#ffffff",
buyShape: "Стрелка вверх", // "Стрелка вверх" | "Круг" | "Квадрат"
sellShape: "Стрелка вниз" // "Стрелка вниз" | "Круг" | "Квадрат"
},
paramMeta: {
// 🎯 Сигналы
signalsEnabled: { label: "🔕 Сигналы", type: "boolean" },
mode: { label: "🎯 Режим сигналов", type: "select", options: ["Отскок","Пробой","Ретест"] },
signalFilter: { label: "Фильтр сигналов", type: "select", options: ["Простые","С фильтрами"] },
bufferAtr: { label: "Буфер события ×ATR", type: "number", min: 0, max: 2, step: 0.05 },
minLevelAgeBars: { label: "Возраст уровня (бары)", type: "number", min: 0, max: 20 },
firstTouchOnly: { label: "Только первое касание", type: "boolean" },
reentryAwayAtr: { label: "Повторный заход: отход ≥ ×ATR", type: "number", min: 0, max: 5, step: 0.05 },
confirmOverMid: { label: "Подтверждение по середине зоны", type: "boolean" },
confirmBufferAtr: { label: "Буфер по середине ×ATR", type: "number", min: 0, max: 2, step: 0.05 },
// 🧱 Уровни
method: { label: "🧱 Метод зоны", type: "select", options: ["По теням","По телам"] },
leftBars: { label: "Пивот слева (L)", type: "number", min: 1, max: 20 },
rightBars: { label: "Подтверждение справа (R)", type: "number", min: 1, max: 20 },
eqTolMode: { label: "🧪 Допуск равенства — режим", type: "select", options: ["×ATR","Пункты"] },
eqTolAtr: { label: "Допуск равенства ×ATR", type: "number", min: 0, max: 0.5, step: 0.01 },
eqTolPoints: { label: "Допуск равенства (пункты)", type: "number", min: 0, max: 100000 },
thicknessMode: { label: "Толщина зоны", type: "select", options: ["Авто","Пункты"] },
zoneAtrMult: { label: "Толщина ×ATR (для Авто)", type: "number", min: 0.05, max: 2, step: 0.05 },
thicknessPoints: { label: "Толщина (пункты)", type: "number", min: 0, max: 100000 },
zoneMinPoints: { label: "Мин. толщина (пункты)", type: "number", min: 0, max: 100000 },
mergeEnabled: { label: "Сливать близкие уровни", type: "boolean" },
// ⛔ События/Продление
keepUntilBreak: { label: "📌 Не удалять до пробоя", type: "boolean" },
stopOn: { label: "⛔ Останов продления (если выключен «до пробоя»)", type: "select", options: ["Касание","Пробой","Оба варианта"] },
expiryBars: { label: "⏱ Экспирация (бары)", type: "number", min: 1, max: 10 },
preSignals: { label: "✨ Предсигналы до закрытия", type: "boolean" },
// ⚙️ Профи
lookbackBars: { label: "⚙️ Профи — Глубина скана (бары)", type: "number", min: 200, max: 20000 },
atrPeriod: { label: "ATR период", type: "number", min: 5, max: 200 },
minImpulseAtr: { label: "Мин. импульс после свинга ×ATR", type: "number", min: 0, max: 5, step: 0.05 },
mergeTolAtr: { label: "Слияние: допуск ×ATR", type: "number", min: 0.05, max: 2, step: 0.05 },
mergeTimeGap: { label: "Слияние: разрыв (бары)", type: "number", min: 0, max: 200 },
touchBy: { label: "Касание по", type: "select", options: ["Тени","Закрытию"] },
breakBy: { label: "Пробой по", type: "select", options: ["Закрытию","Тени"] },
minVolAtr: { label: "Мин. волатильность (ATR)", type: "number", min: 0, max: 100000 },
cooldownBars: { label: "Кулдаун по зоне (бары)", type: "number", min: 0, max: 50 },
minDistanceOppAtr: { label: "Мин. дистанция до встречного уровня ×ATR", type: "number", min: 0, max: 5, step: 0.05 },
maxLevels: { label: "Макс. уровней всего", type: "number", min: 1, max: 60 },
maxLevelsPerSide: { label: "Макс. зон на сторону", type: "number", min: 0, max: 60 },
// 🎨 Вид
fillAlpha: { label: "🎨 Вид — Заливка (прозрачн.)", type: "number", min: 0.05, max: 1, step: 0.05 },
showBorder: { label: "Показывать рамку", type: "boolean" },
borderWidth: { label: "Толщина рамки", type: "number", min: 0, max: 6 },
supColor: { label: "Цвет Поддержек", type: "color" },
resColor: { label: "Цвет Сопротивлений", type: "color" },
mitigatedColor: { label: "Цвет Тестированных", type: "color" },
invalidColor: { label: "Цвет Сломанных", type: "color" },
// 🏷 Маркеры
buyText: { label: "🏷 Маркеры — Текст BUY", type: "string" },
sellText: { label: "Текст SELL", type: "string" },
buyColor: { label: "Цвет стрелки BUY", type: "color" },
sellColor: { label: "Цвет стрелки SELL", type: "color" },
buyTextColor: { label: "Цвет текста BUY", type: "color" },
sellTextColor: { label: "Цвет текста SELL", type: "color" },
buyShape: { label: "Форма BUY", type: "select", options: ["Стрелка вверх","Круг","Квадрат"] },
sellShape: { label: "Форма SELL", type: "select", options: ["Стрелка вниз","Круг","Квадрат"] }
}
};
export function init(ctx){
const { candleSeries, params, createSeriesMarkers } = ctx;
const markersApi = createSeriesMarkers(candleSeries, []);
// ===== Примитив для прямоугольников =====
class ZonesPrimitive {
constructor(o={}){ this._v=new View(o); }
attached(p){ this._v.attached(p); }
detached(){ this._v.detached(); }
paneViews(){ return [this._v]; }
updateAllViews(){ this._v.update(); }
setOptions(o){ this._v.setOptions(o); }
setBoxes(list){ this._v.setBoxes(list); }
}
class View{
constructor(o){
this._s=null; this._c=null; this._req=null;
this._boxes=[]; this._o={ fillAlpha:.12, showBorder:true, borderWidth:1, ...o };
}
attached({series,chart,requestUpdate}){ this._s=series; this._c=chart; this._req=requestUpdate; }
detached(){ this._s=this._c=this._req=null; }
update(){} zOrder(){ return "top"; }
setOptions(o){ Object.assign(this._o,o); this._req?.(); }
setBoxes(list){ this._boxes = Array.isArray(list)? list : []; this._req?.(); }
renderer(){
const self=this;
const drawImpl=(target)=>{
const s=self._s, c=self._c; if(!s||!c) return; const ts=c.timeScale(); if(!ts) return;
const o=self._o;
const draw=(ctx,prX=1,prY=1)=>{
ctx.save(); ctx.scale(prX,prY);
for(const b of self._boxes){
const x1=ts.timeToCoordinate(b.t1), x2=ts.timeToCoordinate(b.t2);
const y1=s.priceToCoordinate(b.top), y2=s.priceToCoordinate(b.bot);
if(x1==null||x2==null||y1==null||y2==null) continue;
const l=Math.min(x1,x2), r=Math.max(x1,x2);
const t=Math.min(y1,y2), B=Math.max(y1,y2);
const w=Math.max(1,r-l), h=Math.max(1,B-t);
ctx.globalAlpha=Math.max(0,Math.min(1,o.fillAlpha));
ctx.fillStyle=b.color; ctx.fillRect(l,t,w,h);
if(o.showBorder && (o.borderWidth|0)>0){
ctx.globalAlpha=1; ctx.lineWidth=Math.max(1,o.borderWidth|0);
ctx.strokeStyle=b.color; ctx.strokeRect(l+.5,t+.5,w,h);
}
}
ctx.restore();
};
if(typeof target.useBitmapCoordinateSpace==="function"){
target.useBitmapCoordinateSpace(sc=>draw(sc.context,sc.horizontalPixelRatio,sc.verticalPixelRatio));
} else draw(target.context,target.pixelRatio||1,target.pixelRatio||1);
};
return { drawBackground: drawImpl, draw: drawImpl };
}
}
let prim = new ZonesPrimitive({
fillAlpha: params.fillAlpha, showBorder: params.showBorder, borderWidth: params.borderWidth
});
if(typeof candleSeries.attachPrimitive==="function") candleSeries.attachPrimitive(prim);
else if(ctx.primitivesApi?.attachPrimitive) ctx.primitivesApi.attachPrimitive(candleSeries, prim);
// ===== Utils =====
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const mapShape=(val,defVal)=>{
if(val==="Стрелка вверх") return "arrowUp";
if(val==="Стрелка вниз") return "arrowDown";
if(val==="Круг") return "circle";
if(val==="Квадрат") return "square";
if(["arrowUp","arrowDown","circle","square"].includes(val)) return val;
return defVal;
};
function atrArr(c,L){
const N=c.length, out=new Array(N).fill(NaN), tr=new Array(N).fill(0);
if(!N) return out; tr[0]=c[0].high-c[0].low;
for(let i=1;i<N;i++){
const a=c[i], b=c[i-1];
tr[i]=Math.max(a.high-a.low, Math.abs(a.high-b.close), Math.abs(a.low-b.close));
}
let s=0; for(let i=0;i<N;i++){ s+=tr[i]; if(i>=L) s-=tr[i-L]; if(i>=L-1) out[i]=s/L; } return out;
}
// Пивоты с допуском (строго L слева и R справа)
function isPH_tol(c,i,L,R,tol){ const v=c[i].high;
for(let k=1;k<=L;k++) if(c[i-k].high >= v - tol) return false;
for(let k=1;k<=R;k++) if(c[i+k].high > v + tol) return false;
return true;
}
function isPL_tol(c,i,L,R,tol){ const v=c[i].low;
for(let k=1;k<=L;k++) if(c[i-k].low <= v + tol) return false;
for(let k=1;k<=R;k++) if(c[i+k].low < v - tol) return false;
return true;
}
function zoneFromPivot(c,i,side,method,half){
const bar=c[i];
if(method==="По телам"){
if(side==='resistance'){ const base=Math.max(bar.open,bar.close); return { top:base+half, bot:base-half, mid:base }; }
const base=Math.min(bar.open,bar.close); return { top:base+half, bot:base-half, mid:base };
} else {
if(side==='resistance'){ const base=bar.high; return { top:base+half, bot:base-half, mid:base }; }
const base=bar.low; return { top:base+half, bot:base-half, mid:base };
}
}
function isTouchAt(c,i,z,cfg){
if(i<0||i>=c.length) return false; const b=c[i];
if(cfg.touchBy==="Закрытию") return b.close>=z.bot && b.close<=z.top;
return b.low<=z.top && b.high>=z.bot;
}
function isBreakAt(c,i,z,cfg,atr){
if(i<0||i>=c.length) return false; const b=c[i];
const buf=(cfg.bufferAtr||0)*(atr[i]||atr[i-1]||0);
if(cfg.breakBy==="Закрытию"){
if(z.side==="support") return b.close<(z.bot-buf);
if(z.side==="resistance") return b.close>(z.top+buf);
} else {
if(z.side==="support") return b.low <(z.bot-buf);
if(z.side==="resistance") return b.high >(z.top+buf);
}
return false;
}
function firstEventIndex(c, z, iStart, iEnd, cfg, atr){
let mit=null, inv=null;
for(let i=iStart;i<=iEnd;i++){
if(mit==null && isTouchAt(c,i,z,cfg)) mit=i;
if(inv==null && isBreakAt(c,i,z,cfg,atr)) inv=i;
if(mit!=null && inv!=null) break;
}
return { mit, inv };
}
const midPrice=(z)=> (z.top+z.bot)/2;
function mergeZones(list,tolPrice,gapBars){
if(!list.length) return [];
const arr=list.slice().sort((a,b)=>a.born-b.born);
const out=[]; let cur=null;
for(const z of arr){
if(!cur){ cur={...z}; continue; }
const sameSide = cur.side===z.side;
const priceClose = Math.abs(z.mid - cur.mid) <= tolPrice;
const timeClose = z.born <= (cur.right ?? cur.born) + gapBars;
if(sameSide && priceClose && timeClose){
cur.mid=(cur.mid+z.mid)/2; cur.top=(cur.top+z.top)/2; cur.bot=(cur.bot+z.bot)/2;
// якорим на СВЕЖЕМ экстремуме, чтобы левый край не «полз» назад
cur.anchor = Math.max(cur.anchor, z.anchor);
cur.born = Math.max(cur.born, z.born); // подтверждение — тоже самое позднее
} else { out.push(cur); cur={...z}; }
}
if(cur) out.push(cur);
return out;
}
function buildZones(candles, atr){
const N=candles.length, endIdx=N-1;
const look=Math.max(200, Math.min((params.lookbackBars|0)||1500, N));
const startIdx=Math.max(5, endIdx - look + 1);
const atrRef=atr[endIdx] || atr.filter(Number.isFinite).slice(-1)[0] || (candles[endIdx].high - candles[endIdx].low);
const L=clamp(params.leftBars|0,1,20);
const R=clamp(params.rightBars|0,1,20);
const eqTol=(i)=> (params.eqTolMode==="×ATR" ? Math.max(0,params.eqTolAtr||0)*(atr[i]||atrRef)
: Math.max(0,params.eqTolPoints||0));
const method=params.method;
const minImpulse=Math.max(0, params.minImpulseAtr||0);
const raw=[];
for(let i=startIdx+L; i<=endIdx-R; i++){
const tol=eqTol(i);
const ph = isPH_tol(candles,i,L,R,tol);
const pl = isPL_tol(candles,i,L,R,tol);
if(ph){
const half = Math.max(
params.thicknessMode==="Авто" ? (params.zoneAtrMult||0.3)*(atr[i]||atrRef)*0.5 : (params.thicknessPoints||0)*0.5,
(params.zoneMinPoints||0)*0.5
);
const z=zoneFromPivot(candles,i,"resistance",method,half);
const next=Math.min(endIdx,i+3);
const imp = Math.max(0,(candles[i].high - candles[next].close))/Math.max(1e-9, atr[i]||atrRef);
if(imp>=minImpulse) raw.push({ side:"resistance", anchor:i, born:i+R, top:z.top, bot:z.bot, mid:z.mid });
}
if(pl){
const half = Math.max(
params.thicknessMode==="Авто" ? (params.zoneAtrMult||0.3)*(atr[i]||atrRef)*0.5 : (params.thicknessPoints||0)*0.5,
(params.zoneMinPoints||0)*0.5
);
const z=zoneFromPivot(candles,i,"support",method,half);
const next=Math.min(endIdx,i+3);
const imp = Math.max(0,(candles[next].close - candles[i].low))/Math.max(1e-9, atr[i]||atrRef);
if(imp>=minImpulse) raw.push({ side:"support", anchor:i, born:i+R, top:z.top, bot:z.bot, mid:z.mid });
}
}
// слияние
let merged=raw;
if(params.mergeEnabled){
const tol=(params.mergeTolAtr||0.25)*atrRef;
const gap=Math.max(0, params.mergeTimeGap|0);
merged = mergeZones(raw, tol, gap);
}
merged.sort((a,b)=>a.born-b.born);
// лимиты по сторонам
const sup=merged.filter(z=>z.side==="support");
const res=merged.filter(z=>z.side==="resistance");
const cut=(arr)=> (params.maxLevelsPerSide>0 ? arr.slice(-params.maxLevelsPerSide) : arr);
let zones = cut(sup).concat(cut(res)).sort((a,b)=>a.born-b.born);
zones = zones.slice(-Math.max(1, params.maxLevels|0));
// правая граница и статус
const cfg={ touchBy: params.touchBy, breakBy: params.breakBy, bufferAtr: (params.signalFilter==="Простые")? 0 : (params.bufferAtr||0) };
for(const z of zones){
const start=Math.min(z.born+1, endIdx);
let right=endIdx;
const ev = firstEventIndex(candles, z, start, endIdx, cfg, atr);
if(params.keepUntilBreak){
// держим до пробоя: тянем до now, останавливаемся только на пробое
if(ev.inv!=null){ right = Math.max(start, ev.inv); }
} else {
if(params.stopOn==="Касание" && ev.mit!=null) right=Math.max(start, ev.mit);
else if(params.stopOn==="Пробой" && ev.inv!=null) right=Math.max(start, ev.inv);
else if(params.stopOn==="Оба варианта"){
const stopIdx = (ev.mit==null)? ev.inv : (ev.inv==null? ev.mit : Math.min(ev.mit,ev.inv));
if(stopIdx!=null) right=Math.max(start, stopIdx);
}
}
z.right = right;
z.status = (ev.inv!=null && ev.inv<=right) ? -1 : ((ev.mit!=null && ev.mit<=right) ? 1 : 0);
}
return { zones, atrRef };
}
function drawZones(candles, zones){
const endIdx=candles.length-1;
const rects=[];
for(const z of zones){
const color = z.status===-1 ? params.invalidColor :
z.status=== 1 ? params.mitigatedColor :
(z.side==="support" ? params.supColor : params.resColor);
const t1 = candles[clamp(z.anchor,0,endIdx)].time; // рисуем ОТ экстремума
const t2 = candles[clamp(z.right, 0,endIdx)].time; // продлеваем по логике
rects.push({ t1, t2, top:z.top, bot:z.bot, color });
}
prim.setOptions({ fillAlpha: params.fillAlpha, showBorder: params.showBorder, borderWidth: params.borderWidth });
prim.setBoxes(rects);
}
// ===== Сигналы =====
function firstTouchIndex(candles, z, cfg, end){ for(let j=z.born; j<=end; j++) if(isTouchAt(candles,j,z,cfg)) return j; return null; }
function hasAwaySince(candles, z, i, atr, thr){
if(thr<=0) return true;
for(let j=z.born; j<i; j++){
const a=atr[j]||atr[j-1]||0;
const d=Math.abs((candles[j].close??candles[j].open) - z.mid);
if(d >= thr*a && (candles[j].close>z.top || candles[j].close<z.bot)) return true;
}
return false;
}
function buildSignals(candles, zones, atr){
if(!params.signalsEnabled) return [];
const N=candles.length, endIdx=N-1;
const simple = params.signalFilter==="Простые";
const cfg = { touchBy: params.touchBy, breakBy: params.breakBy, bufferAtr: simple?0:(params.bufferAtr||0) };
const markers=[];
const bySide={ support: zones.filter(z=>z.side==='support'), resistance: zones.filter(z=>z.side==='resistance') };
const allowOppDistance=(z, aVal)=>{
if(simple) return true;
const others = bySide[z.side==='support'?'resistance':'support'];
if(!others.length) return true;
const near = others.reduce((m,x)=> (m==null || Math.abs(x.mid-z.mid)<Math.abs(m.mid-z.mid)) ? x : m, null);
return !near || Math.abs(near.mid-z.mid) >= (Math.max(0,params.minDistanceOppAtr||0)*aVal);
};
const cooldown=Math.max(0, params.cooldownBars|0);
const lastSig=new Map();
for(const z of zones){
const start=Math.max(z.born + Math.max(0, params.minLevelAgeBars|0), 0);
for(let i=start; i<=Math.min(z.right,endIdx); i++){
const prev=Math.max(0,i-1);
const a=atr[i]||atr[prev]||0;
const awayBuf=(params.bufferAtr||0)*a;
const last=lastSig.get(z) ?? -1;
if(!simple && last>=0 && i-last<=cooldown) continue;
if(params.firstTouchOnly){
const ft = firstTouchIndex(candles, z, cfg, i);
if(ft==null || (simple ? (i!==ft) : (prev!==ft))) continue;
}
if(!hasAwaySince(candles, z, i, atr, Math.max(0, params.reentryAwayAtr||0))) continue;
if(simple){
if(params.mode==="Отскок"){
if(isTouchAt(candles,i,z,cfg)){
if(params.confirmOverMid){
const confBuf=(params.confirmBufferAtr||0)*a;
const needUp = z.side==="support" ? (candles[i].close >= (midPrice(z)+confBuf)) : false;
const needDown = z.side==="resistance" ? (candles[i].close <= (midPrice(z)-confBuf)) : false;
if(!(needUp||needDown)) continue;
}
if(z.side==="support" && allowOppDistance(z,a)){
markers.push({ name:"buy", time:candles[i].time, position:"belowBar",
shape: mapShape(params.buyShape,"arrowUp"), color: params.buyColor,
price:candles[i].close, text: params.buyText||"BUY" });
lastSig.set(z,i);
} else if(z.side==="resistance" && allowOppDistance(z,a)){
markers.push({ name:"sell", time:candles[i].time, position:"aboveBar",
shape: mapShape(params.sellShape,"arrowDown"), color: params.sellColor,
price:candles[i].close, text: params.sellText||"SELL" });
lastSig.set(z,i);
}
}
} else if(params.mode==="Пробой"){
if(isBreakAt(candles,i,z,cfg,atr) && allowOppDistance(z,a)){
if(z.side==="resistance"){
markers.push({ name:"buy", time:candles[i].time, position:"belowBar",
shape: mapShape(params.buyShape,"arrowUp"), color: params.buyColor,
price:candles[i].close, text: params.buyText||"BUY" });
} else {
markers.push({ name:"sell", time:candles[i].time, position:"aboveBar",
shape: mapShape(params.sellShape,"arrowDown"), color: params.sellColor,
price:candles[i].close, text: params.sellText||"SELL" });
}
lastSig.set(z,i);
}
} else if(params.mode==="Ретест"){
let broke=false; for(let j=Math.max(start,i-5); j<i; j++){ if(isBreakAt(candles,j,z,cfg,atr)){ broke=true; break; } }
if(broke && isTouchAt(candles,i,z,cfg) && allowOppDistance(z,a)){
if(z.side==="resistance"){
markers.push({ name:"buy", time:candles[i].time, position:"belowBar",
shape: mapShape(params.buyShape,"arrowUp"), color: params.buyColor,
price:candles[i].close, text: params.buyText||"BUY" });
} else {
markers.push({ name:"sell", time:candles[i].time, position:"aboveBar",
shape: mapShape(params.sellShape,"arrowDown"), color: params.sellColor,
price:candles[i].close, text: params.sellText||"SELL" });
}
lastSig.set(z,i);
}
}
continue;
}
// «С фильтрами»
if(params.mode==="Отскок"){
if(isTouchAt(candles,prev,z,cfg)){
if(z.side==="support"){
if(candles[i].close >= (midPrice(z)+awayBuf) && allowOppDistance(z,a)){
markers.push({ name:"buy", time:candles[i].time, position:"belowBar",
shape: mapShape(params.buyShape,"arrowUp"), color: params.buyColor,
price:candles[i].close, text: params.buyText||"BUY" });
lastSig.set(z,i);
}
} else {
if(candles[i].close <= (midPrice(z)-awayBuf) && allowOppDistance(z,a)){
markers.push({ name:"sell", time:candles[i].time, position:"aboveBar",
shape: mapShape(params.sellShape,"arrowDown"), color: params.sellColor,
price:candles[i].close, text: params.sellText||"SELL" });
lastSig.set(z,i);
}
}
}
} else if(params.mode==="Пробой"){
if(isBreakAt(candles,i,z,cfg,atr) && allowOppDistance(z,a)){
if(z.side==="resistance"){
markers.push({ name:"buy", time:candles[i].time, position:"belowBar",
shape: mapShape(params.buyShape,"arrowUp"), color: params.buyColor,
price:candles[i].close, text: params.buyText||"BUY" });
} else {
markers.push({ name:"sell", time:candles[i].time, position:"aboveBar",
shape: mapShape(params.sellShape,"arrowDown"), color: params.sellColor,
price:candles[i].close, text: params.sellText||"SELL" });
}
lastSig.set(z,i);
}
} else if(params.mode==="Ретест"){
const window=3; let brokeUp=false, brokeDn=false, jBreak=-1;
for(let j=Math.max(start,i-window); j<i; j++){
if(isBreakAt(candles,j,z,cfg,atr)){ if(z.side==="resistance") brokeUp=true; if(z.side==="support") brokeDn=true; jBreak=j; }
}
if(jBreak>=0 && isTouchAt(candles,prev,z,cfg)){
if(z.side==="resistance" && brokeUp){
if(candles[i].close >= (midPrice(z)+awayBuf) && allowOppDistance(z,a)){
markers.push({ name:"buy", time:candles[i].time, position:"belowBar",
shape: mapShape(params.buyShape,"arrowUp"), color: params.buyColor,
price:candles[i].close, text: params.buyText||"BUY" });
lastSig.set(z,i);
}
} else if(z.side==="support" && brokeDn){
if(candles[i].close <= (midPrice(z)-awayBuf) && allowOppDistance(z,a)){
markers.push({ name:"sell", time:candles[i].time, position:"aboveBar",
shape: mapShape(params.sellShape,"arrowDown"), color: params.sellColor,
price:candles[i].close, text: params.sellText||"SELL" });
lastSig.set(z,i);
}
}
}
}
}
}
return markers;
}
function update(candles){
const N=candles?.length||0;
if(N<50){ prim.setBoxes([]); markersApi.setMarkers([]); return []; }
const atr = atrArr(candles, Math.max(5, params.atrPeriod|0));
const atrRef = atr[N-1] || atr.filter(Number.isFinite).slice(-1)[0] || (candles[N-1].high - candles[N-1].low);
if((params.minVolAtr||0)>0 && atrRef<params.minVolAtr){ prim.setBoxes([]); markersApi.setMarkers([]); return []; }
const { zones } = buildZones(candles, atr);
drawZones(candles, zones);
const markers = buildSignals(candles, zones, atr);
const mapped = markers.map(m=>({
...m,
text: m.name==='buy' ? (params.buyText||'BUY') : (params.sellText||'SELL'),
color: m.name==='buy' ? (params.buyColor||'#16a34a') : (params.sellColor||'#ef4444'),
textColor: m.name==='buy' ? (params.buyTextColor||'#ffffff') : (params.sellTextColor||'#ffffff')
}));
markersApi.setMarkers(mapped);
return mapped;
}
function destroy(){
try{
prim?.setBoxes([]);
if(typeof candleSeries.detachPrimitive==="function") candleSeries.detachPrimitive(prim);
else if(typeof candleSeries.removePrimitive==="function") candleSeries.removePrimitive(prim);
}catch(_){}
prim=null; markersApi.setMarkers([]);
}
return { update, destroy };
}
После отправки этого блока GPT должен подтвердить, что всё понял и ждет следующих задач. На этом обучение можно считать завершенным и переходить к практическим задачам.
После того как вы отправили в чат оба кода и получили подтверждение от модели, можно начинать формулировать собственные задачи по созданию визуальных инструментов под ваши нужды.
Примеры запросов:
Создай классический визуальный индикатор состоящий из двух скользящих средних.
Первая линия с настройкой 20, вторая с настройкой 50.
При пересечении линий снизу вверх — сигнал BUY, при пересечении сверху вниз — сигнал SELL.
Создай классические уровни поддержки и сопротивления без сигналов.
Мне нужны только уровни и зоны, без стрелок BUY и SELL.
Создай индикатор KAMA для графика, который будет рисоваться поверх цены.
Добавь параметры для настройки периода и сглаживания.
Создайте новый чистый чат с GPT, чтобы никакие старые инструкции не мешали обучению.
Вставьте текст с фразой «Обучающий промпт» и замените «ТУТ БУДЕТ КОД» на реальный каркас первого индикатора. Дождитесь ответа, что всё понятно.
Вставьте текст второго промпта и замените вторую заглушку «ТУТ БУДЕТ КОД» на каркас инструмента уровней поддержки и сопротивления.
Опишите простыми словами, какой визуальный индикатор или инструмент вам нужен для Spectra Charts и торговли на Pocket Option. Попросите нейросеть выдать готовый код целиком.
Так может выглядеть результат работы обученного GPT и вашего индикатора в Spectra Charts (простой пример уровней и скользящей средней):

Чтобы вам не пришлось гадать, соберём типичные вопросы в один блок.
Создаем осцилляторы для Spectra Charts в отдельном окне под графиком
Пошаговое руководство, как обучить GPT создавать осцилляторы в нижнем окне Spectra Charts и использовать их в торговле бинарными опционами на Pocket Option.
Создаём сигнальный бот в Spectra Charts с метками BUY и SELL
Пошаговый гайд: как обучить ChatGPT, вставить готовый каркас бота с визуальнойаналитикой, написание логики сигналов и создание меток BUY/SELL в Spectra Charts.