javascript

Почему 90% торговых ботов умирают после первого деплоя

  • вторник, 23 декабря 2025 г. в 00:00:11
https://habr.com/ru/articles/979116/
«А сегодня в завтрашний день не все могут смотреть. Вернее смотреть могут не только лишь все, мало кто может это делать»
«А сегодня в завтрашний день не все могут смотреть. Вернее смотреть могут не только лишь все, мало кто может это делать»

Для сравнения доходности торговых стратегий применяют бектест, прокрутка исторических данных для симуляции как алгоритм поведёт себя в той или иной ситуации. 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()

Production-конфигурация из реального проекта

Посмотреть код функции 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;
    },
});

Спасибо за внимание!