Посмотреть код движка бектеста можно по ссылке

Я взял два платных телеграм-канала с сигналами, прочитал месяц их постов (август 2026) через LLM и проиграл каждый сигнал на минутных свечах с комиссиями и слиппеджем. Первый канал дал +10.3% за месяц, второй −18.1%. Обе цифры без плеча. Разница объясняется форматом постов, и её видно ещё до запуска бэктеста.
Зачем это всё
Представим трейдера, который собирается платить за сигналы из телеграма и следовать им. От чего ему нужна страховка?
Стоп-лосс сам по себе не защищает. Можно поставить стоп, который выбивает в 100% случаев, и потерять депозит, формально соблюдая риск-менеджмент. Работают правила автора или нет, показывает только прогон по истории: реальные посты, минутные свечи, комиссии, слиппедж. На выходе получается цифра, которую можно проверить.
Этим я и занялся. Каналы выбрал с противоположными форматами:
-
Канал A в каждом посте публикует полный торговый план.
-
Канал B строит подачу на личной дисциплине: «беру по 1% от депозита», «принимаю убытки и двигаюсь дальше».
Результаты
Канал A
Канал B
Формат сигнала
диапазон входа + 5 целей + стоп
«работаю с BTC в шорт» + скриншот
Сделок за месяц
21
24
Итог (без плеча, за вычетом комиссий)
+10.3%
−18.1%
Прибыльных сделок (win rate)
76% (16 из 21)
29% (7 из 24)
Profit factor
1.78
0.57
Средняя длительность позиции
~17 часов
~44 часа
Худшая сделка
−4.6% (стоп автора)
−7.9% (наш стоп, своих у автора нет)
Рынок, месяц и модель в пайплайне одинаковые, а разница составила 28 процентных пунктов. Дальше разберу, как это посчитано и откуда она взялась.
LLM разбирает посты, решения принимает код
Главное решение в проекте: модель не принимает торговых решений. Ей досталась задача, с которой LLM справляются хорошо, — чтение неструктурированных данных.
Пост вида «Открыть ЛОНГ в диапазоне $78600 — $79400, цели…, стоп $77400» — это текст с опечатками, эмодзи и рекламными вставками. Иногда сигнал вообще не текст, а скриншот терминала со словом «Шорт» на картинке. Превратить это в типизированный JSON должна модель. Решать, входить ли в позицию и где резать убыток, должен код.
import { generateObject, InferenceName, type FormatModel } from "json-inference";const TradingPositionFormat = { type: "object", required: ["id", "symbol", "position", "entryRange", "targets", "stoploss", "reasoning"], properties: { id: { type: "number", description: "ID сообщения-источника сигнала. Если сигнала нет, верни -1", }, position: { type: "string", enum: ["long", "short", "wait"], description: "Тип позиции. Если позиции нет, верни wait", }, entryRange: { type: "object", required: ["from", "to"], properties: { from: { type: "number" }, to: { type: "number" } }, }, targets: { type: "array", description: "Цели позиции, 5 уровней. Если сигнала нет, верни []", items: { type: "number" }, }, stoploss: { type: "number" }, reasoning: { type: "string", description: "Обоснование - для отладки" }, },} satisfies FormatModel;
Насколько это надёжно
Я сравнил два поколения кеша на одинаковых входах. Вердикты и все числовые поля совпали бит в бит. Отличался только свободный текст в reasoning, вплоть до языка ответа. Структуру держит грамматика, цены модель копирует из поста. Для парсера большего и не нужно.
Пайплайн: пост -> сигнал -> сделка
Стратегия тикает каждую минуту по каждому символу. Если на каждом тике звать LLM и Telegram, получим тысячи лишних запросов и флуд-бан. Поэтому получение сигнала обёрнуто в Cache.file: для канала A канал проверяется раз в 5 минут на символ.
Кеш хранится в Mongo. Побочный эффект полезный: коллекция measure-items становится датасетом всех ответов модели за месяц.
import { Cache } from "backtest-kit";import { scrapeLookback } from "telegram-reader";const getSignal = Cache.file( async (symbol: string, when: Date) => { const coin = `#${symbol.replace("USDT", "")}`; // окно 15 минут назад от «сейчас» бэктеста. // Пост, опубликованный позже when, стратегия не увидит let messages = await scrapeLookback({ channel: "crypto_yoda_channel", limit: 15, dimension: "minute", when, }); messages = messages.filter(({ content }) => content.includes(coin)); if (!messages.length) return { entry: null }; // LLM не вызываем const entry = await generateObject( InferenceName.OllamaInference, { format: TradingPositionFormat, messages: [ { role: "user", content: PARSER_PROMPT }, // строка промпта с правилами разбора поста ...messages.map(({ id, date, content }) => ({ role: "user" as const, content: `ID ${id}nn${content}nn[${date.toISOString()}]`, })), ], }, "gemma4:31b-cloud", ); return { entry, message }; }, { interval: "5m", name: "crypto_yoda_entry_v3" },);
Канал A: дисциплина в структуре поста
Каждый сигнальный пост — законченный торговый план:
СИГНАЛ #BTC/USDT🔑 Открыть ЛОНГ в диапазоне $78600 - $79400 - 6% депозита, плечо X15🍒 Цели: $80300 / $80900 / $82000 / $83200 / $84500❗️ СТОП ЛОСС: $77400
Диапазон, пять целей и стоп есть в каждом посте весь месяц. Такой сигнал можно исполнить кодом, ничего не додумывая. Это и есть дисциплина в машиночитаемом виде.
Как стратегия его исполняет
-
Вход — по рынку на любом посте не старше 15 минут. Преимущество сигнала живёт рядом с моментом публикации.
-
Тейк-профит — на самую дальнюю цель.
-
Стоп-лосс — из поста.
-
Сопровождение — «достижение уровней». Каждая взятая цель становится полом. Если цена откатилась за последний взятый уровень больше чем на 30% шага между целями, позиция закрывается.
Шаг считается по сетке целей конкретного поста, поэтому допуск подстраивается сам: у PENGU шаги около 0.3–0.9%, у BTC — сотни долларов.
listenActivePing(async ({ data, currentPrice }) => { const levels = data.payload.levels as number[]; // цели из поста const currentLevel = getCurrentLevel(data.position, levels, currentPrice); const { lastLevel } = await LEVEL_STATE.getState(); // состояние конкретной позиции if (currentLevel > lastLevel) { await LEVEL_STATE.setState({ lastLevel: currentLevel }); // новый пол return; } if (!lastLevel) return; // ни одной цели не взяли - ждём стопа или тейка const drift = getLevelDrift(data.position, levels, lastLevel, currentPrice); const step = getLevelStep(levels, lastLevel); // шаг между соседними целями if (drift > step * LEVEL_DRIFT_RATIO) { await commitClosePending(data.symbol); // откатились за взятый уровень - выходим }});
Итог
21 сделка, +10.3% без плеча за вычетом комиссий, 16 из 21 в плюсе.
-
3 раза сработал стоп автора: от −3.8% до −4.6%, разумный размер.
-
1 раз цена дошла до дальнего тейка.
-
Остальные сделки закрыл рэтчет.
Откуда взялся плюс
Собственный манименеджмент автора я не копировал. Его план — разгружаться по пяти целям с плечом X15–25 — на этих же данных убыточен. Дальние цели почти не достигаются, а один стоп при таком плече сжигает 60–100% маржи.
Плюс получился на стыке: структурированный сигнал автора и наш риск-менеджмент. Направление автора приносит деньги, его плечо сжигает депозит.
Канал B: дисциплина на словах
У Вершинина формат противоположный. Диапазонов, целей и стопов нет. Есть посты:
Вход в позицию
Работаю с BTC - в шорт, взял с учетом возможного добора
Выход из позиции
И так, берем BTC, ETH, SOL в шорт по 1% от депозита,биток и эфир 100 плечо, Солана 50 плечо.
Направление и тикер часто видны только на картинке: скриншоте позиции, графике TradingView или карточке сделки с биржи. Поэтому модель получает не только текст поста, но и изображение:
const signal = await generateObject( InferenceName.OllamaInference, { format: PositionOpenFormat, // { id, symbol, position: long|short|wait, reasoning } messages: [ { role: "user", content: promptOpen }, ...messages.map(({ id, date, content, photo }) => ({ role: "user" as const, content: `ID ${id}nn${content}nn[${date.toISOString()}]`, ...(photo && { images: [photo] }), // картинка поста в base64 - часть сигнала })), ], }, "gemma4:31b-cloud",);
Правила для распознавания картинок я писал только после того, как просмотрел реальные скриншоты канала. На одной ловушке модель ошибалась регулярно: зелёная цифра P&L у шорта означает прибыль, а не «лонг». Направление определяется только по слову «Лонг/Шорт» или по положению зон тейка и стопа на графике. Теперь это правило прописано в промпте явно.
Как стратегия его исполняет
Автор не публикует стопов, поэтому стоп свой: 7.5% от цены входа.
Вход и выход у этого канала — отдельные решения, принятые в разное время. Поэтому пайплайнов два:
-
пока позиции нет, модель ищет в постах вход;
-
пока позиция открыта, модель ищет пост, где автор её закрывает.
addStrategySchema({ strategyName: "main_strategy" }); // схема пустая, без getSignallistenIdlePing(async ({ symbol, when, currentPrice }) => { // тикает, пока позиции нет const { signal } = await getOpenSignal(symbol, when); // Cache.file, 4h if (!signal || signal.position === "wait") return; await commitCreateSignal(symbol, { id: `${signal.id}-${signal.symbol.toLowerCase()}`, symbol: signal.symbol, ...Position.moonbag({ position: signal.position, currentPrice, percentStopLoss: 7.5, // свой стоп - автор своих не публикует }), });});listenActivePing(async ({ symbol, when }) => { // тикает, пока позиция открыта const { signal } = await getCloseSignal(symbol, when); if (signal?.action === "close") await commitClosePending(symbol);});
Итог: 24 сделки, −18.1%, в плюсе 7 из 24.
Выводы
Считайте сами!
Прежде чем платить за сигналы и тем более следовать им, проверьте автора на истории. Это дешевле любой страховки. Весь анализ выше построен на двух файлах backtest.jsonl. В каждой строке — одно событие жизненного цикла позиции. Базовые метрики считаются десятком строк:
import { readFileSync } from "node:fs";const trades = readFileSync("backtest_yoda.jsonl", "utf8") .split("n").filter(Boolean).map(JSON.parse) .filter((e) => e.data.action === "closed");const pnl = trades.reduce((sum, e) => sum + e.data.pnl, 0);const wins = trades.filter((e) => e.data.pnl > 0).length;const grossProfit = trades.filter((e) => e.data.pnl > 0).reduce((s, e) => s + e.data.pnl, 0);const grossLoss = trades.filter((e) => e.data.pnl s + e.data.pnl, 0);console.log({ trades: trades.length, totalPnl: pnl.toFixed(2), // +10.26 winRate: (wins / trades.length * 100).toFixed(0) + "%", // 76% profitFactor: (grossProfit / -grossLoss).toFixed(2), // 1.78});
В каждом событии closed есть:
-
цены входа и выхода;
-
причина закрытия:
take_profit,stop_loss,closedилиtime_expired; -
максимальная прибыль и максимальная просадка по позиции;
-
в поле
note— полный ответ модели и пост-источник.
Любую сделку можно отмотать до конкретного телеграм-поста и прочитать reasoning модели. Метод переносится на любой другой канал за вечер.
Инструменты
-
backtest-kit — движок бэктеста и live-торговли с защитой от look-ahead.
-
telegram-reader — чтение телеграм-каналов через MTProto.
-
json-inference — structured output для ollama.
-
backtest-ollama-casual — репозиторий с обеими стратегиями и скриптами анализа.
Спасибо за внимание
Источник: habr.com