Полноценный trading engine для экосистемы NodeJS

Вы можете посмотреть репо проекта по ссылке

Последние пять лет экосистема Node.js стабилизировалась. Ничего принципиально нового в ней не появляется: мы обсуждаем, чем Fastify лучше Express, выбираем между Prisma и Drizzle, спорим про ESM против CommonJS и меняем один бандлер на другой, который делает то же самое, но быстрее. Ниши поделены, инструменты зрелые, хайп ушёл в соседние рантаймы. Это хорошая новость для продакшена и скучная — для инженера.

Тем интереснее, когда в экосистеме открывается целая область применения.

Backtest как один из режимов исполнения

Библиотека представляет собой набор инструментов для алгоритмической торговли, который расширяет сферу применения Node.js туда, где исторически безраздельно правил Python: Backtrader, VectorBT, Freqtrade. Одна и та же торговая стратегия работает как в live, так и backtest без изменений

Дашборд backtest-kitДашборд backtest-kit

Разница принципиальная. Бектестер отвечает на вопрос: «Что было бы, если прогнать стратегию по истории?» Архитектура backtest-kit отвечает на вопрос шире: «Как стратегия существует и исполняется внутри торговой системы — исторически и в реальном времени

Вот целая стратегия — три регистрации и запуск. Ни бутстрапа, ни DI-контейнера, ни цикла по свечам:

import ccxt from "ccxt";import { addExchangeSchema, addFrameSchema, addStrategySchema, Position, Backtest, listenSignalBacktest,} from "backtest-kit";// Откуда брать свечиaddExchangeSchema({ exchangeName: "binance", getCandles: async (symbol, interval, since, limit) => { const ohlcv = await new ccxt.binance().fetchOHLCV(symbol, interval, since.getTime(), limit); return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume })); },});// Какой период истории проигрыватьaddFrameSchema({ frameName: "feb-2026", interval: "1m", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-28"),});// Что делать: чистая функция «состояние рынка -> сигнал или null»addStrategySchema({ strategyName: "my-strategy", interval: "15m", getSignal: async (symbol, when, currentPrice) => ({ position: "long", ...Position.bracket({ position: "long", currentPrice, percentTakeProfit: 2, percentStopLoss: 1 }), minuteEstimatedTime: 60 * 24, cost: 100, }),});Backtest.background("BTCUSDT", { strategyName: "my-strategy", exchangeName: "binance", frameName: "feb-2026",});listenSignalBacktest(console.log);

Ниже — разбор, почему это именно engine, по пунктам.

Backtest и Live используют одну модель исполнения

Это не отдельный симулятор, прикрученный сбоку к стратегии. В системе один execution flow: как для backtest, так и для live режима. В режиме backtest этот flow воспроизводится на исторических данных, в live — работает с текущим рынком. Функция getSignal, которую вы гоняли по истории, — байт в байт та же функция, что торгует вживую. Меняется только источник часов:

// Backtest - часы двигает исторический фреймBacktest.background("BTCUSDT", { strategyName, exchangeName, frameName });// Live - часы двигает wall-clock; файл стратегии не изменился ни на строчкуLive.background("BTCUSDT", { strategyName, exchangeName });// Paper - живые цены, ни одного реального ордера, тот же код-путь

Тесты фреймворка (1030+ юнит- и интеграционных) прямо защищают это свойство: валидация сигналов, немедленная и отложенная активация, проверки TP/SL/timeout идентичны в обоих режимах, и если пути когда-нибудь разойдутся — тест упадёт раньше, чем ваш депозит. Практическое следствие: исчезает классическая болезнь «в ноутбуке стратегия была прибыльной, а в проде переписали — и она другая». Переписывать нечего.

Engine управляет жизненным циклом сделки, а не просто считает PnL

Сигнал в системе — не строка в итоговой таблице, а конечный автомат. У каждого состояния — только те поля, которые в нём осмысленны:

listenSignal((event) => { switch (event.action) { case "idle": /* сигнала нет - есть только цена и контекст */ break; case "scheduled": /* лимитка ждёт цену - есть priceOpen, scheduledAt */ break; case "opened": /* только что вошли - есть entry, но нет closeReason */ break; case "active": /* позиция живёт - pnl, peakProfit, maxDrawdown */ break; case "closed": /* вышли - closeReason и финальный pnl; live-полей нет */ break; }});

Прочитать «живой PnL» у закрытой позиции — это не баг, который ловят в QA, это строка, которая не скомпилируется. Система моделирует именно trade lifecycle — entry, partial exit, DCA-докупки, активация, отмена, длительность — а не арифметику «цена входа минус цена выхода».

Портфель закрытых сигналовПортфель закрытых сигналов

Stateful execution и восстановление состояния

Live-engine сохраняет состояние pending signal и persisted signal state атомарно (запись во временный файл + rename) и после перезапуска продолжает с последней консистентной точки. Убили процесс во время выставления ордера — внутреннее состояние не изменилось, повтор на следующем тике. Пропала сеть — ретрай. Отключили питание во время записи — восстановление с последней атомарной записи:

listenSignalLive(async (event) => { if (event.action === "closed") { await Live.dump(event.symbol, event.strategyName); // атомарный снапшот на диск await Partial.dump(event.symbol, event.strategyName); } if (event.action === "scheduled" || event.action === "cancelled") { await Schedule.dump(event.symbol, event.strategyName); }});

Для обычного бектестера crash recovery вообще не является задачей: упал — перезапустил расчёт. Для торгового рантайма это вопрос выживания позиции, и здесь он решён структурно, а не флагом в конфиге.

Persistence — часть runtime-архитектуры

Персистентность здесь — не «сохранить результаты теста в CSV». Это пятнадцать с лишним отдельных контрактов IPersist*Instance: свечи, сигналы, расписания, риск-состояние, partial-закрытия, breakeven, интервалы, measures, логи, память LLM-агента, сессии. Каждый заменяется собственным адаптером, а переезд с файлов на продакшен-бэкенд — один вызов:

// config/setup.config.ts - грузится один раз до первого обращения к персистентностиimport { setup } from "@backtest-kit/mongo"; // или @backtest-kit/pg, @backtest-kit/miniosetup(); // все 16 контрактов переехали на MongoDB + Redis O(1)-кэш. // В коде стратегии не изменилось ничего.

@backtest-kit/pg (PostgreSQL + Pgpool-II) даёт ~4× ускорение чтений за счёт read-реплик, @backtest-kit/minio — S3 как source of truth с Redis-индексом времени. Выбор бэкенда — вопрос инфраструктуры, не trading-логики.

Настоящий event-driven execution layer

Engine работает через события и типизированные контракты, а не через одну функцию runBacktest(). Есть streaming progress, completion events, validation notifications и целое семейство lifecycle-событий с Once и PerSignal вариантами. Это программная модель React для трейдинга: getSignal — чистая render-функция, listen* — слой эффектов, а цикл принадлежит движку. Поведение собирается добавлением независимых обработчиков, а не редактированием цикла:

// Эффект №1: трейлинг-тейк - закрыть при откате 1% от пикового профитаlistenActivePing(async ({ symbol }) => { if (await getPositionPnlPercent(symbol) < 0) return; if (await getPositionHighestProfitDistancePnlPercentage(symbol)  { if (await getPositionHighestPnlPercentage(symbol) < 1.0) return; if (await getPositionHighestProfitMinutes(symbol)  Log.debug("error", { message: getErrorMessage(error) }));

Обработчики выполняются последовательно через очередь, так что асинхронный колбэк не устроит гонку.

Лента событий жизненного циклаЛента событий жизненного цикла

Отдельный runtime для Live

Live execution — это бесконечный async-цикл с троттлингом сигналов по интервалу, persisted state, отложенными сигналами и graceful shutdown. Live.background() при остановке не убивает процесс — он ждёт, пока открытая позиция дойдёт до состояния closed, и только потом отдаёт done event. Деплой никогда не обрывает живую сделку посередине. Это характеристики production runtime, а не аналитического инструмента.

Backtest — режим исполнения engine, а не его суть

Исторические данные просто становятся источником market events. Механизм стратегии, execution context, валидация, риск-слой — всё остаётся тем же. Поэтому кодовая база не распадается на «backtest implementation» и «production implementation» с их неизбежным дрейфом.

Полноценный execution context

Стратегия работает не с голыми свечами, переданными массивом. Через AsyncLocalStorage сквозь всю цепочку await’ов (включая Promise.all) течёт ambient-контекст: какая стратегия, какая биржа, какой символ, и главное — «сейчас». У getCandles нет параметра timestamp, который можно забыть:

getSignal: async (symbol) => { // Ни одного таймстемпа. Контекст течёт даже сквозь Promise.all - // все четыре таймфрейма автоматически приколочены к одному тику. const [c1h, c15m, c5m, c1m] = await Promise.all([ getCandles(symbol, "1h", 24), getCandles(symbol, "15m", 48), getCandles(symbol, "5m", 60), getCandles(symbol, "1m", 60), ]); // Свечу из будущего получить нельзя: движок отдаёт только [since, now), // а ещё не закрытая текущая свеча исключена - её полусырой OHLC // не отравит индикаторы.};

Look-ahead bias устранён не дисциплиной, а отсутствием поверхности для ошибки.

Управление ордерами и позицией на уровне домена

Partial exits, DCA, average buy, отложенная активация по цене, trailing stop/take, breakeven, profit-lock — система описывает не «купили -> продали», а реальные механики управления позицией. Вот полная DCA-лестница из боевого примера (+67.85% за апрель 2026): открыться, докупать на просадках до 10 ступеней, закрыться по blended-цели — около тридцати строк, вся опасная математика внутри движка:

// render: открыть LONG без фиксированного TP, hard stop 25%addStrategySchema({ strategyName: "apr_2026_strategy", getSignal: async (symbol, when, currentPrice) => ({ position: "long", ...Position.moonbag({ position: "long", currentPrice, percentStopLoss: 25 }), minuteEstimatedTime: Infinity, cost: 100, }),});// эффект: лестница - докупить $100 на просадке вне ±1–5% коридора от входовlistenActivePing(async ({ symbol, currentPrice }) => { if ((await getPositionEntries(symbol)).length >= 10) return; if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return; await commitAverageBuy(symbol, 100); // докупка ВЫШЕ эффективной цены будет отклонена движком});// эффект: выход, когда blended PnL всей лестницы дошёл до +3%listenActivePing(async ({ symbol }) => { if (await getPositionPnlPercent(symbol) < 3) return; await commitClosePending(symbol, { id: "target", note: "# Закрыто по target pnl" });});

Ключевая строка — комментарий к commitAverageBuy: усреднение вверх структурно запрещено. Эффективная цена считается как cost-weighted harmonic mean (корректно для входов фиксированной суммой), каждый partial снапшотит свой cost basis, и итоговый PnL сходится при перекрёстной проверке двумя независимыми способами.

Детали позиции и manual controlДетали позиции и manual control

Risk layer как часть архитектуры

Риск-профили регистрируются и валидируются отдельным сервисом, причём на уровне портфеля: ClientRisk видит все открытые позиции всех стратегий сразу, а checkSignalAndReserve атомарно резервирует слот между «проверили» и «ордер ушёл»:

addRiskSchema({ riskName: "demo", validations: [ // TP не ближе 1% - иначе комиссии съедят сделку ({ pendingSignal, currentPrice }) => { const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal; const tp = position === "long" ? ((priceTakeProfit - priceOpen) / priceOpen) * 100 : ((priceOpen - priceTakeProfit) / priceOpen) * 100; if (tp  { const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal; const reward = position === "long" ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit; const risk = position === "long" ? priceOpen - priceStopLoss : priceStopLoss - priceOpen; if (reward / risk  { await Risk.dump(event.symbol, event.strategyName); });

Десять стратегий, каждая «рискует 10%», больше не означают счёт под 100% экспозиции. Risk management — часть engine architecture, а не внешний скрипт вокруг прогона.

Cron — инфраструктура, а не if в цикле

Отложенные сигналы (лимитка ждёт цену), их отмена, ручная активация, статистика времени ожидания — отдельная подсистема с собственной персистентностью и отчётами. Сигнал существует во времени до момента исполнения, как в реальной торговой системе. Сверху — Cron, который работает на виртуальном времени: в бектесте, проигрывающем месяц за три секунды, задачи срабатывают на границах свечей:

Cron.register({ name: "tg-parser", interval: "1h", // глобально, раз в час handler: async ({ when }) => { await parseTelegramSignals(when); } });Cron.register({ name: "funding", interval: "1h", // fan-out по символам symbols: ["BTCUSDT", "ETHUSDT"], handler: async ({ symbol, when }) => { await fetchFundingRate(symbol, when); } });Cron.enable(); // один вызов - каждый тик движка дальше форвардится сам

В live тот же API крутит реальный re-polling; параллельные бектесты на одной границе координируются mutex-семантикой — одна граница никогда не сработает дважды.

Market-data infrastructure

Свечи не грузятся одним массивом для расчёта индикатора. Есть candle persistence (каждая свеча — иммутабельная запись, first write wins), прогрев кэша, проверка полноты данных, дедупликация запросов (девять стратегий, запросивших одну свечу BTCUSDT 1m, порождают один запрос к бирже, а не девять) и TimeMetaService, отслеживающий актуальность market data между тиками:

for (const symbol of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"]) { await warmCandles({ exchangeName: "binance", interval: "1m", symbol, from: new Date("2026-02-01T00:00:00Z"), to: new Date("2026-02-28T23:59:59Z") }); Backtest.background(symbol, { strategyName, exchangeName: "binance", frameName: "feb-2026" });}// Пять символов параллельно в одном процессе Node - без форков и IPC

Результат на обычном ноутбуке: девять символов параллельно, ~703× реального времени на символ, ~6300× суммарно.

Research/optimization layer — поверх engine, а не вместо него

Walker (A/B-сравнение стратегий на одной истории с ранжированным отчётом) и Sweep (grid-перебор параметров по фиду краудсорсных торговых идей с грейдингом авторов) — не сам engine. Они его потребители:

# Прогнать три варианта стратегии по одной истории и получить ранжированный отчётnpx @backtest-kit/cli --walker --symbol BTCUSDT --markdown --output feb_comparison  ./content/feb_v1.strategy.ts ./content/feb_v2.strategy.ts ./content/feb_v3.strategy.ts# -> ./dump/feb_comparison.md

Research-инфраструктура построена над execution model — ровно так, как это устроено во взрослых торговых системах.

Heatmap производительности по символамHeatmap производительности по символам

Schema/registry architecture

Strategy, Exchange, Frame, Risk, Sizing, Walker, Sweep, Action, MCP — всё регистрируется через schema-сервисы с shallow-валидацией и возможностью частичного override*. Engine — платформа, на которой живут разные стратегии и execution-модули. Биржа подключается одной схемой — хоть CCXT/Binance, хоть MongoDB с распарсенными сделками региональной биржи, которой нет на TradingView:

addExchangeSchema({ exchangeName: "mongo-exchange", // Узбекская фондовая биржа, Монголия, Бангладеш - что угодно getCandles: async (symbol, interval, since, limit) => CandleModel.find({ symbol, interval, timestamp: { $gte: since.getTime() } }) .sort({ timestamp: 1 }).limit(limit).lean(),});

Graceful shutdown

При остановке live runtime система не убивает процесс: дожидается завершения последнего действия, доводит позицию до терминального состояния и только после этого генерирует done event. Первый Ctrl+C в CLI останавливает все активные прогоны через штатный stop, второй — force-quit. Мелкая деталь, по которой безошибочно отличаешь production runtime от аналитической тулзы.

Streaming execution

Async-генераторы здесь — не украшение API. Один engine, два способа потребления — по задаче, а не по возможностям:

// Event-driven - для production-ботов и мониторингаBacktest.background("BTCUSDT", config);listenSignalBacktest((e) => { /* … */ });// Async-итератор - для research, скриптов и LLM-агентовfor await (const event of Backtest.run("BTCUSDT", config)) { // signal | progress | done - по мере работы движка, без аккумуляции в памяти, // с возможностью break - ранней остановки прогона}

Именно на этом построен веб-дашборд @backtest-kit/ui: живые графики с оверлеями сигналов, KPI-борд, heatmap по портфелю, лента нотификаций и ручное управление позицией — кнопки Manual Control дёргают те же broker-хуки, что и стратегия.

KPI-дашбордKPI-дашборд

Инфраструктура заменяется без переписывания trading logic

Persistence, биржевые зависимости, нотификации, логгер — всё за интерфейсами и адаптерами. Broker-адаптер перехватывает каждую мутацию состояния до её применения: бросил исключение — состояние не изменилось, ретрай на следующем тике. Rollback руками не пишется никогда:

Broker.useBrokerAdapter(class implements Partial { async onOrderOpenCommit({ symbol, cost, priceOpen, priceTakeProfit, priceStopLoss }) { const ex = await getExchange(); const qty = truncateQty(ex, symbol, cost / priceOpen); await createLimitOrderAndWait(ex, symbol, "buy", qty, priceOpen); // не заполнился -> throw -> ретрай await ex.createOrder(symbol, "limit", "sell", qty, priceTakeProfit); // защита сразу после входа await createStopLossOrder(ex, symbol, qty, priceStopLoss); } // onOrderCloseCommit · onPartialProfitCommit · onTrailingStopCommit // onBreakevenCommit · onAverageBuyCommit - все хуки опциональны});Broker.enable(); // в backtest-режиме адаптер не вызывается вообще

Намерение адаптера декларируется тремя типизированными ошибками: OrderTransientError («временно, повтори»), OrderRejectedError («биржа отказала окончательно, повторять бессмысленно»), OrderDeletedError («ордера больше нет — позицию закрыть, лимитку отменить»). Счётчики повторов ограничены и переживают краш процесса.

Попробовать

# Проект с примером стратегииnpx @backtest-kit/cli --init --output my-trading-botcd my-trading-bot && npm install && npm start# Или «eject» - вся обвязка редактируемым кодом в вашем репозиторииnpx -y @backtest-kit/sidekick my-trading-bot

Всё под MIT, ядро не имеет жёстких зависимостей от дополнительных пакетов, персистентность — обычные файлы или ваша собственная БД. В репозитории — девять production-quality примеров с записанными цифрами: от нейросети на TensorFlow и Python-индикатора через WASM до инверсии сигналов телеграм-канала (Sharpe 1.14) и DCA-лестниц — каждый с честным описанием рисков и трейд-логом.

Ссылки:

  • GitHub: https://github.com/tripolskypetr/backtest-kit

  • npm: https://www.npmjs.com/package/backtest-kit

  • Документация: https://backtest-kit.github.io

  • Примеры стратегий: https://github.com/tripolskypetr/backtest-kit/tree/master/example

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев