DEV Community

Cover image for ๐Ÿ’ป A Full-Fledged Trading Engine for the Node.js Ecosystem
Petr Tripolsky
Petr Tripolsky

Posted on

๐Ÿ’ป A Full-Fledged Trading Engine for the Node.js Ecosystem

You can check out the project repo here

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:

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);
Enter fullscreen mode Exit fullscreen mode

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:

// 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
Enter fullscreen mode Exit fullscreen mode

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:

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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:

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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:

// 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.
Enter fullscreen mode Exit fullscreen mode

@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:

// 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) }));
Enter fullscreen mode Exit fullscreen mode

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:

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.
};
Enter fullscreen mode Exit fullscreen mode

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:

// 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" });
});
Enter fullscreen mode Exit fullscreen mode

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":

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); });
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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:

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(),
});
Enter fullscreen mode Exit fullscreen mode

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:

// 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
}
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

# 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
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)