Created
September 18, 2026 18:00
-
-
Save tripolskypetr/5b76719943849719877f0117bad628c6 to your computer and use it in GitHub Desktop.
ARTICLE.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # A Full-Fledged Trading Engine for the Node.js Ecosystem | |
| > You can check out [the project repo here](https://github.com/tripolskypetr/backtest-kit) | |
|  | |
| Over the past five years the Node.js ecosystem has stabilized. Nothing fundamentally new shows up in it: we discuss why Fastify beats Express, choose between Prisma and Drizzle, argue about ESM versus CommonJS, and swap one bundler for another that does the same thing, only faster. The niches are taken, the tools are mature, the hype has moved to neighboring runtimes. That is good news for production and boring news for an engineer. | |
| Which makes it all the more interesting when an entire new application domain opens up in the ecosystem. | |
| ## Backtest as one of the execution modes | |
| > The library is a toolkit for algorithmic trading that extends Node.js into territory historically ruled by Python alone: Backtrader, VectorBT, Freqtrade. The very same trading strategy runs in both live and backtest without changes | |
|  | |
| *backtest-kit dashboard* | |
| The difference is fundamental. A backtester answers the question: "What would have happened if I ran this strategy over history?" The backtest-kit architecture answers a broader one: "How does a strategy exist and execute inside a trading system - historically **and in real time**?" | |
| Here is an entire strategy - three registrations and a launch. No bootstrap, no DI container, no loop over candles: | |
| ```typescript | |
| import ccxt from "ccxt"; | |
| import { | |
| addExchangeSchema, addFrameSchema, addStrategySchema, | |
| Position, Backtest, listenSignalBacktest, | |
| } from "backtest-kit"; | |
| // Where candles come from | |
| 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 })); | |
| }, | |
| }); | |
| // Which period of history to replay | |
| addFrameSchema({ | |
| frameName: "feb-2026", | |
| interval: "1m", | |
| startDate: new Date("2026-02-01"), | |
| endDate: new Date("2026-02-28"), | |
| }); | |
| // What to do: a pure function "market state -> signal or 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); | |
| ``` | |
| Below is a point-by-point breakdown of why this is an engine and not just a backtester. | |
| ## Backtest and Live share a single execution model | |
| This is not a separate simulator bolted onto the side of a strategy. The system has one execution flow for both backtest and live modes. In backtest mode that flow is replayed over historical data; in live mode it works against the current market. The `getSignal` function you ran over history is, byte for byte, the same function that trades live. Only the clock source changes: | |
| ```typescript | |
| // Backtest - the clock is driven by a historical frame | |
| Backtest.background("BTCUSDT", { strategyName, exchangeName, frameName }); | |
| // Live - the clock is wall-clock; the strategy file hasn't changed by a single line | |
| Live.background("BTCUSDT", { strategyName, exchangeName }); | |
| // Paper - live prices, not a single real order, the same code path | |
| ``` | |
| The framework's tests (1030+ unit and integration tests) guard this property directly: signal validation, immediate and deferred activation, and TP/SL/timeout checks are identical in both modes, and if the paths ever diverge, a test fails before your deposit does. The practical consequence: the classic disease of "the strategy was profitable in the notebook, then it got rewritten for prod and became a different strategy" disappears. There is nothing to rewrite. | |
| ## The engine manages the trade lifecycle, not just PnL | |
| A signal in the system is not a row in a results table - it is a finite state machine. Each state carries only the fields that are meaningful in it: | |
| ```typescript | |
| listenSignal((event) => { | |
| switch (event.action) { | |
| case "idle": | |
| /* no signal - only price and context */ | |
| break; | |
| case "scheduled": | |
| /* limit order waiting for price - priceOpen, scheduledAt are present */ | |
| break; | |
| case "opened": | |
| /* just entered - entry is present, closeReason is not */ | |
| break; | |
| case "active": | |
| /* position is alive - pnl, peakProfit, maxDrawdown */ | |
| break; | |
| case "closed": | |
| /* exited - closeReason and final pnl; no live fields */ | |
| break; | |
| } | |
| }); | |
| ``` | |
| Reading the "live PnL" of a closed position is not a bug that QA catches - it is a line that does not compile. The system models the actual trade lifecycle - entry, partial exit, DCA buys, activation, cancellation, duration - rather than the arithmetic of "entry price minus exit price". | |
|  | |
| *Closed signals portfolio* | |
| ## Stateful execution and state recovery | |
| The live engine persists pending signal state and persisted signal state atomically (write to a temp file + rename) and, after a restart, resumes from the last consistent point. Process killed while placing an order - internal state is unchanged, retry on the next tick. Network dropped - retry. Power cut mid-write - recovery from the last atomic write: | |
| ```typescript | |
| listenSignalLive(async (event) => { | |
| if (event.action === "closed") { | |
| await Live.dump(event.symbol, event.strategyName); // atomic snapshot to disk | |
| await Partial.dump(event.symbol, event.strategyName); | |
| } | |
| if (event.action === "scheduled" || event.action === "cancelled") { | |
| await Schedule.dump(event.symbol, event.strategyName); | |
| } | |
| }); | |
| ``` | |
| For an ordinary backtester, crash recovery is not a concern at all: it crashed - rerun the calculation. For a trading runtime it is a matter of the position's survival, and here it is solved structurally, not with a config flag. | |
| ## Persistence is part of the runtime architecture | |
| Persistence here is not "save the test results to CSV". It is fifteen-plus separate `IPersist*Instance` contracts: candles, signals, schedules, risk state, partial closes, breakeven, intervals, measures, logs, LLM agent memory, sessions. Each one can be replaced with its own adapter, and moving from files to a production backend is a single call: | |
| ```typescript | |
| // config/setup.config.ts - loaded once, before the first access to persistence | |
| import { setup } from "@backtest-kit/mongo"; // or @backtest-kit/pg, @backtest-kit/minio | |
| setup(); // all 16 contracts have moved to MongoDB + a Redis O(1) cache. | |
| // Nothing changed in the strategy code. | |
| ``` | |
| `@backtest-kit/pg` (PostgreSQL + Pgpool-II) gives ~4× faster reads thanks to read replicas; `@backtest-kit/minio` uses S3 as the source of truth with a Redis time index. Choosing a backend is an infrastructure question, not a trading-logic one. | |
| ## A real event-driven execution layer | |
| The engine works through events and typed contracts, not through a single `runBacktest()` function. There is streaming progress, completion events, validation notifications, and a whole family of lifecycle events with `*Once` and `*PerSignal` variants. It is React's programming model applied to trading: `getSignal` is a pure render function, `listen*` is the effects layer, and the loop belongs to the engine. Behavior is composed by adding independent handlers, not by editing the loop: | |
| ```typescript | |
| // Effect #1: trailing take - close on a 1% pullback from peak profit | |
| listenActivePing(async ({ symbol }) => { | |
| if (await getPositionPnlPercent(symbol) < 0) return; | |
| if (await getPositionHighestProfitDistancePnlPercentage(symbol) < 1.0) return; | |
| await commitClosePending(symbol, { id: "trailing", note: "# Closed by trailing take" }); | |
| }); | |
| // Effect #2: peak staleness - the peak was long ago, no movement, get out | |
| listenActivePing(async ({ symbol }) => { | |
| if (await getPositionHighestPnlPercentage(symbol) < 1.0) return; | |
| if (await getPositionHighestProfitMinutes(symbol) < 240) return; | |
| await commitClosePending(symbol, { id: "staleness", note: "# Closed by peak staleness" }); | |
| }); | |
| // Effect #3: a single point for all errors | |
| listenError((error) => Log.debug("error", { message: getErrorMessage(error) })); | |
| ``` | |
| Handlers run sequentially through a queue, so an async callback cannot cause a race. | |
|  | |
| *Lifecycle event feed* | |
| ## A dedicated runtime for Live | |
| Live execution is an infinite async loop with interval-based signal throttling, persisted state, scheduled signals, and graceful shutdown. When stopped, `Live.background()` does not kill the process - it waits until the open position reaches the `closed` state and only then emits the done event. A deploy never cuts a live trade in half. These are the traits of a production runtime, not of an analytics tool. | |
| ## Backtest is an execution mode of the engine, not its essence | |
| Historical data simply becomes the source of market events. The strategy mechanism, the execution context, validation, the risk layer - all of it stays the same. That is why the codebase does not split into a "backtest implementation" and a "production implementation" with their inevitable drift. | |
| ## A proper execution context | |
| A strategy does not work with bare candles passed in as an array. An ambient context flows through the entire await chain (including `Promise.all`) via `AsyncLocalStorage`: which strategy, which exchange, which symbol, and most importantly - "now". `getCandles` has no timestamp parameter that you could forget: | |
| ```typescript | |
| getSignal: async (symbol) => { | |
| // Not a single timestamp. The context flows even through Promise.all - | |
| // all four timeframes are automatically pinned to the same tick. | |
| const [c1h, c15m, c5m, c1m] = await Promise.all([ | |
| getCandles(symbol, "1h", 24), | |
| getCandles(symbol, "15m", 48), | |
| getCandles(symbol, "5m", 60), | |
| getCandles(symbol, "1m", 60), | |
| ]); | |
| // You cannot get a candle from the future: the engine only returns [since, now), | |
| // and the current, not-yet-closed candle is excluded - its half-baked OHLC | |
| // will not poison your indicators. | |
| }; | |
| ``` | |
| Look-ahead bias is eliminated not by discipline but by the absence of any surface to make the mistake on. | |
| ## Domain-level order and position management | |
| Partial exits, DCA, average buy, deferred price-based activation, trailing stop/take, breakeven, profit-lock - the system describes not "bought -> sold" but the real mechanics of managing a position. Here is a complete DCA ladder from a production example (+67.85% for April 2026): open, buy the dips for up to 10 rungs, close on a blended target - about thirty lines, with all the dangerous math inside the engine: | |
| ```typescript | |
| // render: open a LONG with no fixed TP, hard stop at 25% | |
| addStrategySchema({ | |
| strategyName: "apr_2026_strategy", | |
| getSignal: async (symbol, when, currentPrice) => ({ | |
| position: "long", | |
| ...Position.moonbag({ position: "long", currentPrice, percentStopLoss: 25 }), | |
| minuteEstimatedTime: Infinity, | |
| cost: 100, | |
| }), | |
| }); | |
| // effect: the ladder - buy another $100 on a dip outside the ±1–5% corridor around entries | |
| 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); // a buy ABOVE the effective price will be rejected by the engine | |
| }); | |
| // effect: exit once the blended PnL of the whole ladder reaches +3% | |
| listenActivePing(async ({ symbol }) => { | |
| if (await getPositionPnlPercent(symbol) < 3) return; | |
| await commitClosePending(symbol, { id: "target", note: "# Closed by target pnl" }); | |
| }); | |
| ``` | |
| The key line is the comment next to `commitAverageBuy`: averaging up is structurally forbidden. The effective price is computed as a cost-weighted harmonic mean (which is correct for fixed-amount entries), every partial snapshots its own cost basis, and the final PnL reconciles when cross-checked by two independent methods. | |
|  | |
| *Position details and manual control* | |
| ## The risk layer as part of the architecture | |
| Risk profiles are registered and validated by a separate service, and at the portfolio level at that: `ClientRisk` sees every open position of every strategy at once, and `checkSignalAndReserve` atomically reserves a slot between "checked" and "order sent": | |
| ```typescript | |
| addRiskSchema({ | |
| riskName: "demo", | |
| validations: [ | |
| // TP no closer than 1% - otherwise fees eat the trade | |
| ({ 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 too close: ${tp.toFixed(2)}%`); | |
| }, | |
| // Risk/Reward no worse than 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("Poor R/R"); | |
| }, | |
| ], | |
| }); | |
| // Every rejection is an event, not a silent skip | |
| listenRisk(async (event) => { await Risk.dump(event.symbol, event.strategyName); }); | |
| ``` | |
| Ten strategies, each "risking 10%", no longer add up to an account at 100% exposure. Risk management is part of the engine architecture, not an external script wrapped around a run. | |
| ## Cron is infrastructure, not an if inside a loop | |
| Scheduled signals (a limit order waiting for its price), their cancellation, manual activation, wait-time statistics - this is a separate subsystem with its own persistence and reports. A signal exists in time before the moment of execution, just as in a real trading system. On top of that sits `Cron`, which runs on *virtual* time: in a backtest that replays a month in three seconds, jobs fire on candle boundaries: | |
| ```typescript | |
| Cron.register({ name: "tg-parser", interval: "1h", // global, once an hour | |
| handler: async ({ when }) => { await parseTelegramSignals(when); } }); | |
| Cron.register({ name: "funding", interval: "1h", // fan-out across symbols | |
| symbols: ["BTCUSDT", "ETHUSDT"], | |
| handler: async ({ symbol, when }) => { await fetchFundingRate(symbol, when); } }); | |
| Cron.enable(); // one call - from here on every engine tick is forwarded automatically | |
| ``` | |
| In live mode the same API drives real re-polling; parallel backtests hitting the same boundary are coordinated with mutex semantics - a single boundary never fires twice. | |
| ## Market-data infrastructure | |
| Candles are not loaded as one array just to compute an indicator. There is candle persistence (every candle is an immutable record, first write wins), cache warming, data completeness checks, request deduplication (nine strategies asking for the same BTCUSDT 1m candle produce one request to the exchange, not nine), and a `TimeMetaService` that tracks market-data freshness between ticks: | |
| ```typescript | |
| 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" }); | |
| } | |
| // Five symbols in parallel in a single Node process - no forks, no IPC | |
| ``` | |
| The result on an ordinary laptop: nine symbols in parallel, ~703× real time per symbol, ~6300× in aggregate. | |
| ## The research/optimization layer sits on top of the engine, not in place of it | |
| `Walker` (A/B comparison of strategies over the same history with a ranked report) and `Sweep` (a grid search of parameters over a feed of crowdsourced trading ideas, with author grading) are not the engine itself. They are its consumers: | |
| ```bash | |
| # Run three variants of a strategy over the same history and get a ranked report | |
| 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 | |
| ``` | |
| The research infrastructure is built on top of the execution model - exactly the way it is done in grown-up trading systems. | |
|  | |
| *Per-symbol performance heatmap* | |
| ## Schema/registry architecture | |
| Strategy, Exchange, Frame, Risk, Sizing, Walker, Sweep, Action, MCP - everything is registered through schema services with shallow validation and support for partial `override*`. The engine is a platform that different strategies and execution modules live on. An exchange is plugged in with a single schema - be it CCXT/Binance or a MongoDB holding parsed trades from a regional exchange that isn't on TradingView: | |
| ```typescript | |
| addExchangeSchema({ | |
| exchangeName: "mongo-exchange", // Uzbekistan's stock exchange, Mongolia, Bangladesh - anything | |
| getCandles: async (symbol, interval, since, limit) => | |
| CandleModel.find({ symbol, interval, timestamp: { $gte: since.getTime() } }) | |
| .sort({ timestamp: 1 }).limit(limit).lean(), | |
| }); | |
| ``` | |
| ## Graceful shutdown | |
| When the live runtime is stopped, the system does not kill the process: it waits for the last action to finish, brings the position to a terminal state, and only then emits the done event. The first Ctrl+C in the CLI stops all active runs through the regular stop path; the second one is a force-quit. A small detail that unmistakably separates a production runtime from an analytics tool. | |
| ## Streaming execution | |
| Async generators here are not API decoration. One engine, two ways to consume it - chosen by task, not by capability: | |
| ```typescript | |
| // Event-driven - for production bots and monitoring | |
| Backtest.background("BTCUSDT", config); | |
| listenSignalBacktest((e) => { /* … */ }); | |
| // Async iterator - for research, scripts and LLM agents | |
| for await (const event of Backtest.run("BTCUSDT", config)) { | |
| // signal | progress | done - as the engine runs, with no accumulation in memory, | |
| // and with the option to break - stopping the run early | |
| } | |
| ``` | |
| This is exactly what the `@backtest-kit/ui` web dashboard is built on: live charts with signal overlays, a KPI board, a portfolio heatmap, a notification feed, and manual position control - the Manual Control buttons call the same broker hooks that the strategy does. | |
|  | |
| *KPI dashboard* | |
| ## Infrastructure is swapped out without rewriting trading logic | |
| Persistence, exchange dependencies, notifications, the logger - all of it sits behind interfaces and adapters. The broker adapter intercepts every state mutation *before* it is applied: throw an exception and the state is unchanged, with a retry on the next tick. You never write a rollback by hand: | |
| ```typescript | |
| 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); // not filled -> throw -> retry | |
| await ex.createOrder(symbol, "limit", "sell", qty, priceTakeProfit); // protection right after entry | |
| await createStopLossOrder(ex, symbol, qty, priceStopLoss); | |
| } | |
| // onOrderCloseCommit · onPartialProfitCommit · onTrailingStopCommit | |
| // onBreakevenCommit · onAverageBuyCommit - all hooks are optional | |
| }); | |
| Broker.enable(); // in backtest mode the adapter is never called at all | |
| ``` | |
| The adapter declares its intent through three typed errors: `OrderTransientError` ("temporary, try again"), `OrderRejectedError` ("the exchange refused for good, retrying is pointless"), `OrderDeletedError` ("the order no longer exists - close the position, cancel the limit order"). Retry counters are bounded and survive a process crash. | |
| ## Try it | |
| ```bash | |
| # A project with an example strategy | |
| npx @backtest-kit/cli --init --output my-trading-bot | |
| cd my-trading-bot && npm install && npm start | |
| # Or "eject" - all the scaffolding as editable code in your own repository | |
| npx -y @backtest-kit/sidekick my-trading-bot | |
| ``` | |
| Everything is MIT-licensed, the core has no hard dependencies on the additional packages, and persistence is plain files or your own database. The repository contains nine production-quality examples with recorded numbers: from a TensorFlow neural network and a Python indicator via WASM to inverting the signals of a Telegram channel (Sharpe 1.14) and DCA ladders - each with an honest description of the risks and a trade log. | |
| **Links:** | |
| - GitHub: https://github.com/tripolskypetr/backtest-kit | |
| - npm: https://www.npmjs.com/package/backtest-kit | |
| - Documentation: https://backtest-kit.github.io | |
| - Strategy examples: https://github.com/tripolskypetr/backtest-kit/tree/master/example |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment