Почему 90% торговых ботов умирают после первого деплоя
- вторник, 23 декабря 2025 г. в 00:00:11

Для сравнения доходности торговых стратегий применяют бектест, прокрутка исторических данных для симуляции как алгоритм поведёт себя в той или иной ситуации. Look-ahead bias - когда бэктест подглядывает в будущее. То есть, использует данные, которых в момент принятия решения ещё не было.
// Прямая передача массива с историческими данными
function shouldBuy(candles, idx) {
const currentPrice = candles[idx].close;
const nextPrice = candles[idx + 1].close;
return nextPrice > currentPrice; // НЕВЕРНО!
// Нужно было сравнить idx - 1 и idx - 2
}На практике есть три паттерна влияния на бектест
1. Индикаторы загружены без фильтрации по времени
public getSignal = async (candles, currentTime) => {
const validCandles = candles.filter(c => c.timestamp < currentTime);
const rsi = this.calculateRSI(candles);
// Передали просто candles? Бэктест врёт.
}2. В расчёты попала одна лишняя свеча
const validCandles = candles.filter(c => c.timestamp < currentTime);
// Тут должно быть < или <= ? 3. Данные "просочились" из следующего тика
this.calculateRSI(validCandles)
// Этот метод не statelessДля решения проблемы нужно вынести все функции, определающие поведение стратегии, в plain js object, рассчёт временного окна вынести в общий библиотечный код
import { AsyncLocalStorage } from 'async_hooks';
const backtestContext = new AsyncLocalStorage();
// Для каждого тика фиксируем "сейчас"
async function processTick(timestamp, symbol) {
const context = { currentTime: timestamp };
// Весь код внутри живёт в этом времени
await backtestContext.run(context, async () => {
const signal = await strategy.getSignal(symbol); // strategy это просто структура, с функцией
await processSignal(signal); // а не кастомный класс с свойствами
});
}Получение свечей для анализа сразу использует backtestContext инкапсулируя математику от прикладного программиста
async function getCandles(symbol, interval, limit) {
const context = backtestContext.getStore();
// ВСЕГДА отдаёт данные только ДО context.currentTime
// Будущее физически недоступно
return await exchange.getCandles(
symbol,
interval,
context.currentTime, // Из контекста автоматом
limit
);
}Главная фишка: абсолютно одинаковый код в обоих режимах.
1. Бектест
import { Backtest, listenSignalBacktest, listenBacktestProgress } from "backtest-kit";
Backtest.background("BTCUSDT", {
strategyName: "test_strategy",
exchangeName: "test_exchange",
frameName: "test_frame",
});
listenBacktestProgress((event) => {
console.log(`Прогресс: ${(event.progress * 100).toFixed(2)}%`);
});
listenSignalBacktest((event) => {
console.log(event);
});2. Прод
import { Live, listenSignalLive } from "backtest-kit";
Live.background("BTCUSDT", {
strategyName: "test_strategy",
exchangeName: "test_exchange",
frameName: "test_frame",
});
listenSignalLive(async (event) => {
if (event.action === "opened") {
console.log("Открываем позу");
}
if (event.action === "closed") {
console.log("Закрываем позу");
await Live.dump(event.symbol, event.strategyName);
}
});Вся разница:
Бэктест: context.currentTime из истории
Прод: context.currentTime=Date.now()
Посмотреть код функции json, использующей DeepSeek-V3 для генерации торгового сигнала, можно по ссылке
import ccxt from "ccxt";
import { addExchange, addStrategy, addFrame, addRisk } from "backtest-kit";
import { v4 as uuid } from "uuid";
import { json } from "./utils/json.mjs";
import { getMessages } from "./utils/messages.mjs";
// 1. Configure exchange (CCXT integration)
addExchange({
exchangeName: "test_exchange",
getCandles: async (symbol, interval, since, limit) => {
const exchange = new ccxt.binance();
const ohlcv = await exchange.fetchOHLCV(
symbol,
interval,
since.getTime(),
limit
);
return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({
timestamp, open, high, low, close, volume
}));
},
formatPrice: async (symbol, price) => price.toFixed(2),
formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
});
// 2. Risk management rules
addRisk({
riskName: "demo_risk",
validations: [
{
validate: ({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
if (!priceOpen) return;
// Calculate TP distance percentage
const tpDistance = position === "long"
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
if (tpDistance < 1) {
throw new Error(`TP distance ${tpDistance.toFixed(2)}% < 1%`);
}
},
note: "TP distance must be at least 1%",
},
{
validate: ({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
if (!priceOpen) return;
// Calculate reward (TP distance)
const reward = position === "long"
? priceTakeProfit - priceOpen
: priceOpen - priceTakeProfit;
// Calculate risk (SL distance)
const risk = position === "long"
? priceOpen - priceStopLoss
: priceStopLoss - priceOpen;
if (risk <= 0) {
throw new Error("Invalid SL: risk must be positive");
}
const rrRatio = reward / risk;
if (rrRatio < 2) {
throw new Error(`RR ratio ${rrRatio.toFixed(2)} < 2:1`);
}
},
note: "Risk-Reward ratio must be at least 1:2",
},
],
});
// 3. Define timeframe
addFrame({
frameName: "test_frame",
interval: "1m",
startDate: new Date("2025-12-01T00:00:00.000Z"),
endDate: new Date("2025-12-01T23:59:59.000Z"),
});
// 4. Strategy logic
addStrategy({
strategyName: "test_strategy",
interval: "5m",
riskName: "demo_risk",
getSignal: async (symbol) => {
// getMessages internally calls getCandles
// which automatically respects temporal context
const messages = await getMessages(symbol);
const resultId = uuid();
// Creates a trading signal using Ollama
const result = await json(messages);
await dumpSignal(resultId, messages, result);
result.id = resultId;
return result;
},
});