Building a Risk-Aware Crypto Trading Bot in Node.js
Most tutorials about crypto trading bots start with a signal: RSI crosses 30, MACD turns positive, price breaks a moving average, then the bot buys. That is useful, but it is not where production trading systems usually fail. They fail in the gaps between the signal and the order: reconnect storms, stale prices, duplicated orders, partial fills, incorrect position state, runaway leverage, and strategies that keep trading after the environment has changed.
In this tutorial, we will design a small Node.js trading bot architecture that treats risk controls as first-class code. The goal is not to promise profit. The goal is to build a bot that can receive market data, make decisions, place orders, and stop itself when the assumptions behind the strategy are no longer valid.
We will use three ideas:
- A state machine for the bot lifecycle
- WebSocket market data with freshness checks
- Risk gates and kill switches before every order
The examples are exchange-neutral, but the same structure works with Binance, Bybit, OKX, Coinbase, or any other venue that exposes WebSocket streams and REST order endpoints.
1. Start With Explicit Bot States
A trading bot should never be "just running." It should always be in a known state. That makes failures easier to debug and makes dangerous transitions harder to trigger accidentally.
Here is a minimal state model:
const BotState = Object.freeze({
BOOTING: "BOOTING",
SYNCING: "SYNCING",
READY: "READY",
TRADING: "TRADING",
PAUSED: "PAUSED",
KILL_SWITCH: "KILL_SWITCH",
ERROR: "ERROR"
});
let state = BotState.BOOTING;
function transition(next, reason) {
console.log(`[state] ${state} -> ${next}: ${reason}`);
state = next;
}
Before the bot is allowed to trade, it should sync account balances, open positions, outstanding orders, exchange time, symbol filters, and risk settings. A common beginner mistake is to start from an empty local state and assume the account is flat. If the exchange already has an open position, the bot may accidentally double exposure.
async function boot(exchange) {
transition(BotState.SYNCING, "loading exchange state");
const [balances, positions, orders, exchangeInfo] = await Promise.all([
exchange.getBalances(),
exchange.getOpenPositions(),
exchange.getOpenOrders(),
exchange.getExchangeInfo()
]);
return {
balances,
positions,
orders,
exchangeInfo,
lastTickAt: 0,
dailyLossUsd: 0,
consecutiveErrors: 0
};
}
Only after this sync should the bot move to READY. From there, it can enter TRADING when market data is fresh and risk checks pass.
2. Treat Market Data Freshness as a Risk Control
WebSockets feel real-time, but they can silently become stale if the connection is degraded. A strategy using a price from 20 seconds ago can be worse than no strategy at all.
Track the timestamp of the last valid tick:
function onTicker(ctx, tick) {
ctx.lastTickAt = Date.now();
ctx.lastPrice = Number(tick.price);
}
function hasFreshMarketData(ctx, maxAgeMs = 3000) {
return Date.now() - ctx.lastTickAt <= maxAgeMs;
}
Then make freshness mandatory before evaluation:
async function loop(ctx, strategy, exchange) {
if (state !== BotState.TRADING) return;
if (!hasFreshMarketData(ctx)) {
transition(BotState.PAUSED, "market data is stale");
await exchange.cancelAllOrders("BTCUSDT");
return;
}
const signal = strategy.evaluate({
price: ctx.lastPrice,
position: ctx.positions["BTCUSDT"],
balances: ctx.balances
});
await handleSignal(ctx, signal, exchange);
}
This is a simple guard, but it prevents a large class of mistakes. If the bot cannot see the market reliably, it should not trade.
3. Put a Risk Gate Before Every Order
Signals should not place orders directly. They should request intent. A risk module decides whether that intent is allowed.
For example, the strategy may say:
const signal = {
action: "BUY",
symbol: "BTCUSDT",
confidence: 0.72,
reason: "breakout above 20-period high"
};
The risk gate turns that into an approved order or a rejection:
function checkRisk(ctx, signal) {
if (state !== BotState.TRADING) {
return reject("bot is not trading");
}
if (!hasFreshMarketData(ctx)) {
return reject("stale market data");
}
if (ctx.dailyLossUsd <= -100) {
return reject("daily loss limit reached");
}
const position = ctx.positions[signal.symbol];
const currentExposure = Math.abs(position?.notionalUsd || 0);
if (currentExposure >= 500) {
return reject("max exposure reached");
}
if (signal.confidence < 0.6) {
return reject("signal confidence too low");
}
return {
ok: true,
order: {
symbol: signal.symbol,
side: signal.action,
type: "MARKET",
notionalUsd: 50
}
};
}
function reject(reason) {
return { ok: false, reason };
}
Notice that the strategy is not trusted blindly. It provides a recommendation. The risk layer owns permission.
4. Add Idempotency to Order Placement
Duplicate orders are one of the most painful bot bugs. They happen when an API request times out, the exchange accepts the order, and the bot retries because it did not receive the response.
Use a client order ID for every order:
function makeClientOrderId({ strategy, symbol, side }) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${strategy}-${symbol}-${side}-${timestamp}-${random}`;
}
async function placeApprovedOrder(exchange, approved) {
const clientOrderId = makeClientOrderId({
strategy: "breakout-v1",
symbol: approved.order.symbol,
side: approved.order.side
});
return exchange.createOrder({
...approved.order,
clientOrderId
});
}
If the request fails, do not immediately send a new market order. First query by clientOrderId or refresh open orders and recent fills. The retry path should answer one question: did the exchange already accept this intent?
5. Build a Kill Switch That Cancels First
A kill switch is not just a boolean. It is a procedure. When triggered, it should stop evaluation, cancel open orders, optionally reduce positions, and prevent automatic restart.
async function triggerKillSwitch(ctx, exchange, reason) {
transition(BotState.KILL_SWITCH, reason);
try {
await exchange.cancelAllOrders("BTCUSDT");
console.log("[risk] open orders cancelled");
} catch (err) {
console.error("[risk] failed to cancel orders", err);
}
ctx.killSwitchReason = reason;
ctx.killSwitchAt = new Date().toISOString();
}
Useful kill switch triggers include:
- Daily loss limit exceeded
- Too many consecutive API errors
- Position size exceeds configured maximum
- Market data is stale for too long
- Account equity differs sharply from local expectations
- Strategy emits too many orders in a short window
The important detail is that kill switch recovery should be manual. If the bot can automatically restart after a critical failure, the kill switch is only a pause button.
6. Wire the Decision Flow
Now the core flow is straightforward:
async function handleSignal(ctx, signal, exchange) {
if (!signal || signal.action === "HOLD") return;
const approved = checkRisk(ctx, signal);
if (!approved.ok) {
console.log(`[risk] rejected signal: ${approved.reason}`);
return;
}
try {
const order = await placeApprovedOrder(exchange, approved);
console.log(`[order] submitted ${order.id}`);
} catch (err) {
ctx.consecutiveErrors += 1;
console.error("[order] failed", err);
if (ctx.consecutiveErrors >= 3) {
await triggerKillSwitch(ctx, exchange, "too many order errors");
}
}
}
This structure keeps strategy, risk, and execution separate. That separation matters because you will change strategies often, but your risk rules should remain stable and boring.
7. What to Log
For every trading decision, log enough information to reconstruct what happened later:
- Timestamp
- Bot state
- Symbol and last price
- Signal action and reason
- Position before the decision
- Risk approval or rejection reason
- Order ID or client order ID
- Any API error response
This does not require a complex observability stack at the beginning. A structured JSON log file is already better than console.log strings that cannot be queried.
function logDecision(event) {
console.log(JSON.stringify({
ts: new Date().toISOString(),
...event
}));
}
When a bot behaves unexpectedly, logs are the difference between improving the system and guessing.
Final Thoughts
A profitable signal can still lose money if the execution layer is careless. A mediocre signal with strong risk controls is easier to improve than an aggressive signal wrapped in fragile code.
When building a Node.js crypto trading bot, start with the parts that protect the account: state, synchronization, market data freshness, exposure limits, idempotent orders, and kill switches. Then plug strategies into that foundation.
I am building Lucromatic, a trading automation platform focused on practical bot workflows and risk-aware execution. Demo: try.lucromatic.com
Top comments (0)