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