17 часов назад
Полноценный trading engine для экосистемы NodeJS

Вы можете посмотреть репо проекта по ссылке
Последние пять лет экосистема Node.js стабилизировалась. Ничего принципиально нового в ней не появляется: мы обсуждаем, чем Fastify лучше Express, выбираем между Prisma и Drizzle, спорим про ESM против CommonJS и меняем один бандлер на другой, который делает то же самое, но быстрее. Ниши поделены, инструменты зрелые, хайп ушёл в соседние рантаймы. Это хорошая новость для продакшена и скучная - для инженера.
Тем интереснее, когда в экосистеме открывается целая область применения.
Backtest как один из режимов исполнения
Библиотека представляет собой набор инструментов для алгоритмической торговли, который расширяет сферу применения Node.js туда, где исторически безраздельно правил Python: Backtrader, VectorBT, Freqtrade. Одна и та же торговая стратегия работает как в live, так и backtest без изменений

Разница принципиальная. Бектестер отвечает на вопрос: «Что было бы, если прогнать стратегию по истории?» Архитектура 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/minio setup(); // все 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) < 1.0) return; await commitClosePending(symbol, { id: "trailing", note: "# Закрыто по trailing take" }); }); // Эффект №2: peak staleness - пик был давно, движения нет, выходим listenActivePing(async ({ symbol }) => { if (await getPositionHighestPnlPercentage(symbol) < 1.0) return; if (await getPositionHighestProfitMinutes(symbol) < 240) return; await commitClosePending(symbol, { id: "staleness", note: "# Закрыто по peak staleness" }); }); // Эффект №3: единая точка для всех ошибок listenError((error) => 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 сходится при перекрёстной проверке двумя независимыми способами.

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 < 1) throw new Error(`TP слишком близко: ${tp.toFixed(2)}%`); }, // Risk/Reward не хуже 2:1 ({ pendingSignal, currentPrice }) => { 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 < 2) throw new Error("Плохой R/R"); }, ], }); // Каждое отклонение - событие, а не молчаливый пропуск listenRisk(async (event) => { 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 тот же программный интерфейс крутит фактический 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 - ровно так, как это устроено во взрослых торговых системах.

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-генераторы здесь - не украшение программный интерфейс. Один 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/пользовательский интерфейс: живые графики с оверлеями сигналов, KPI-борд, heatmap по портфелю, лента нотификаций и ручное управление позицией - кнопки Manual Control дёргают те же broker-хуки, что и стратегия.

Инфраструктура заменяется без переписывания trading logic
Persistence, биржевые зависимости, нотификации, логгер - всё за интерфейсами и адаптерами. Broker-адаптер перехватывает каждую мутацию состояния до её применения: бросил исключение - состояние не изменилось, ретрай на следующем тике. Rollback руками не пишется никогда:
Broker.useBrokerAdapter(class implements Partial<IBroker> { 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-bot cd 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-лестниц - каждый с честным описанием рисков и трейд-логом.
Ссылки:
Документация: https://backtest-kit.github.io
Примеры стратегий: https://github.com/tripolskypetr/backtest-kit/tree/master/example
Читают сейчас

19 минут назад
Anthropic запустили в бете обновлённые Projects в Claude Code
Вместо ручной группировки сессий теперь единое окно для всех задач проекта. Ставите цель, подключаете репозитории, дальше он раскидывает работу по тредам*, запускает их параллельно, демонстрирует стат
33 минуты назад
AMD повысит цены на продукцию на 10% в четвёртом квартале
AMD заявила партнёрам, что планирует повысить цены примерно на 10% на некоторые CPU в четвёртом квартале 2026 года. На такой шаг организация идёт из‑за увеличения производственных расходов тайваньской

49 минут назад
В Anthropic работают приблизительно 30 000 ИИ‑агентов или как Claude разрабатывает себя сам
По внутреннему индексу автоматизации самих Anthropic, Claude уже ведёт 26% работы в области разработки ИИ: получает задачу на высоком уровне и выполняет большую часть работы самостоятельно, оставаясь

53 минуты назад
OpenAI и Microsoft признали уничтожение веба со стороны LLM
Руководители Microsoft и OpenAI, работающие над ИИ, признали, что большие языковые модели отличаются «поразительной кражей беспрецедентных масштабов» при обучении. Во внутреннем документе Microsoft го
1 час назад
Razer представила кейпад Tartarus V2 Pro
Компания Razer представила Tartarus V2 Pro — обновлённую версию своего популярного игрового контроллера. Девайс оснащено регулируемой подставкой под ладонь. Ознакомиться далее