Building a Risk-Aware Crypto Execution Queue in Node.js
Most crypto bot tutorials stop at the exciting part: fetch candles, calculate an indicator, and send a market order. That is useful for learning, but it is not how a resilient trading system should behave in production. The fragile part is almost always the small gap between "the strategy wants to trade" and "the exchange accepted the order."
That gap is where duplicate signals, partial failures, rate limits, stale prices, and emotional position sizing can quietly turn a reasonable idea into a dangerous bot.
In this tutorial, we will design a risk-aware execution queue in Node.js. The goal is not to predict the market. The goal is to build the boring layer that makes any strategy safer: one path for orders, explicit risk checks, idempotency, retries, and audit logs.
Why use an execution queue?
A trading strategy should decide intent. An execution engine should decide whether that intent is still allowed.
For example, your strategy might emit this:
{
symbol: "BTC/USDT",
side: "buy",
reason: "ema_cross_15m",
confidence: 0.72
}
That is not an order yet. It is a request. Before it becomes an order, the system should answer a few questions:
- Is the bot already in a position for this symbol?
- Is the request too old?
- Would the order exceed max position size?
- Is the exchange reachable?
- Has this exact signal already been processed?
- Are we inside the daily loss limit?
An execution queue gives you one controlled place to ask those questions.
Project setup
Create a small Node.js project:
mkdir crypto-execution-queue
cd crypto-execution-queue
npm init -y
npm install pino
For this tutorial, we will mock the exchange adapter so the architecture is clear. In a real bot, you can plug in CCXT, a direct exchange SDK, or your own REST/WebSocket client.
Define trade intents
Start with an intent factory. Every request gets a deterministic idempotency key. This prevents one signal from becoming multiple orders if your strategy retries, your process restarts, or a message gets delivered twice.
import crypto from "node:crypto";
export function createTradeIntent({ symbol, side, strategy, timeframe, candleTime, confidence }) {
const rawKey = `${strategy}:${symbol}:${side}:${timeframe}:${candleTime}`;
const idempotencyKey = crypto.createHash("sha256").update(rawKey).digest("hex");
return {
idempotencyKey,
symbol,
side,
strategy,
timeframe,
candleTime,
confidence,
createdAt: Date.now()
};
}
Notice that the key does not include the current timestamp. That is intentional. If the same strategy emits the same signal for the same candle, we want the same key.
Add a risk gate
The risk gate is a pure function that receives current state and a trade intent. It returns an approval or a rejection. Keeping this logic separate makes it much easier to test.
const MAX_SIGNAL_AGE_MS = 60_000;
const MAX_OPEN_POSITIONS = 3;
const MAX_DAILY_LOSS_USDT = 150;
const MAX_CONFIDENCE_ORDER_USDT = 500;
const BASE_ORDER_USDT = 100;
export function evaluateRisk(intent, state) {
if (Date.now() - intent.createdAt > MAX_SIGNAL_AGE_MS) {
return { approved: false, reason: "stale_signal" };
}
if (state.processedKeys.has(intent.idempotencyKey)) {
return { approved: false, reason: "duplicate_signal" };
}
if (state.dailyPnl <= -MAX_DAILY_LOSS_USDT) {
return { approved: false, reason: "daily_loss_limit" };
}
if (!state.positions.has(intent.symbol) && state.positions.size >= MAX_OPEN_POSITIONS) {
return { approved: false, reason: "max_open_positions" };
}
const confidenceBoost = Math.max(0, intent.confidence - 0.5) * 2;
const notional = Math.min(
BASE_ORDER_USDT * (1 + confidenceBoost),
MAX_CONFIDENCE_ORDER_USDT
);
return {
approved: true,
notional,
reason: "approved"
};
}
This is deliberately simple. The key idea is that risk decisions are explicit and logged. You can later replace these constants with configuration, add volatility-based sizing, or check account margin before approval.
Create the execution queue
Now we need a queue that processes one intent at a time. Serial execution is underrated. Many exchange bugs happen because two asynchronous parts of the bot both believe they are allowed to trade.
import pino from "pino";
import { evaluateRisk } from "./risk.js";
const log = pino({ level: "info" });
export class ExecutionQueue {
constructor({ exchange, state }) {
this.exchange = exchange;
this.state = state;
this.queue = [];
this.running = false;
}
enqueue(intent) {
this.queue.push(intent);
this.run().catch((error) => {
log.error({ error }, "execution_queue_failed");
});
}
async run() {
if (this.running) return;
this.running = true;
try {
while (this.queue.length > 0) {
const intent = this.queue.shift();
await this.process(intent);
}
} finally {
this.running = false;
}
}
async process(intent) {
const decision = evaluateRisk(intent, this.state);
if (!decision.approved) {
log.warn({ intent, decision }, "trade_rejected");
this.state.processedKeys.add(intent.idempotencyKey);
return;
}
const ticker = await this.exchange.getTicker(intent.symbol);
const amount = decision.notional / ticker.price;
const order = await this.exchange.createMarketOrder({
symbol: intent.symbol,
side: intent.side,
amount,
clientOrderId: intent.idempotencyKey
});
this.state.processedKeys.add(intent.idempotencyKey);
this.state.positions.set(intent.symbol, {
symbol: intent.symbol,
side: intent.side,
amount,
entryPrice: ticker.price,
orderId: order.id
});
log.info({ intent, order, amount, price: ticker.price }, "trade_executed");
}
}
The queue does three important things:
- It prevents concurrent order placement.
- It marks duplicate or rejected requests as processed.
- It uses
clientOrderIdso the exchange can also help with idempotency when supported.
Mock the exchange adapter
For local testing, use a fake adapter:
export const mockExchange = {
async getTicker(symbol) {
return {
symbol,
price: 65_000 + Math.random() * 500
};
},
async createMarketOrder({ symbol, side, amount, clientOrderId }) {
return {
id: `order_${Date.now()}`,
symbol,
side,
amount,
clientOrderId,
status: "filled"
};
}
};
This makes the queue easy to test without touching real funds. Once the behavior is correct, the exchange adapter becomes an implementation detail.
Wire everything together
Here is a tiny runnable example:
import { ExecutionQueue } from "./execution-queue.js";
import { createTradeIntent } from "./intent.js";
import { mockExchange } from "./mock-exchange.js";
const state = {
processedKeys: new Set(),
positions: new Map(),
dailyPnl: 0
};
const queue = new ExecutionQueue({
exchange: mockExchange,
state
});
const intent = createTradeIntent({
symbol: "BTC/USDT",
side: "buy",
strategy: "ema_cross",
timeframe: "15m",
candleTime: "2026-06-16T12:00:00.000Z",
confidence: 0.72
});
queue.enqueue(intent);
queue.enqueue(intent);
The first intent should execute. The second should be rejected as a duplicate. That is exactly what you want when the market is moving quickly and your system is under pressure.
Production upgrades
Before using this pattern with real capital, add persistence. In-memory Set and Map objects are fine for a tutorial, but production bots should store processed keys, positions, and order events in a database. SQLite is enough for a small single-server bot. Postgres is better when you expect multiple services, dashboards, or analytics.
You should also add retry rules with care. Retrying a failed request is not always safe. A network timeout after order submission does not mean the order failed. It might mean the exchange accepted the order and your client never received the response. The safer flow is:
- Submit with a client order id.
- On timeout, query the exchange for that client order id.
- Only resubmit if the exchange confirms it does not exist.
Finally, add a kill switch. A simple environment-controlled flag, database setting, or admin command can stop new executions while still allowing the bot to read market data and manage existing positions.
Final thoughts
Profitable strategies matter, but execution discipline keeps a bot alive long enough to learn whether the strategy is actually good. A queue-based execution layer gives your Node.js trading system a clean boundary between signal generation and capital movement.
The pattern is small: create intents, evaluate risk, process sequentially, log every decision, and make every order idempotent. That small layer can prevent many of the expensive mistakes that happen when a trading bot grows from a weekend script into a real system.
I am building Lucromatic, a platform for automated crypto trading workflows and execution tooling. Demo: try.lucromatic.com
For further actions, you may consider blocking this person and/or reporting abuse
