DEV Community

ShuenShukusai
ShuenShukusai

Posted on

Running Crypto Trading Bots on Hyperliquid 24/7 — Tech Stack, Architecture & Lessons Learned

Introduction

I run three types of trading bots on Hyperliquid, an on-chain order book DEX, 24/7 on a VPS: Grid Bots across 5 pairs, a Funding Rate arbitrage bot, and a Liquidation Cascade detector.

This is a technical deep dive into the stack, architecture decisions, and real operational challenges. No trading advice — just engineering.

Why Hyperliquid

Unlike AMM-based DEXes, Hyperliquid runs a full order book on its own L1 chain. For bot developers, this means:

  • Limit orders (not just market orders like AMMs)
  • Real-time order book data via WebSocket
  • Maker fee: 0.01% (with VIP rebates)
  • Taker fee: 0.035%
  • Near-zero gas costs
  • REST + WebSocket APIs, ccxt support
REST: https://api.hyperliquid.xyz
WebSocket: wss://api.hyperliquid.xyz/ws
Enter fullscreen mode Exit fullscreen mode

Tech Stack

Runtime:          Node.js
Exchange API:     ccxt (Hyperliquid supported)
Tx Signing:       ethers.js v5
Database:         SQLite (WAL mode)
Process Manager:  PM2
Server:           Ubuntu 22.04 VPS
Enter fullscreen mode Exit fullscreen mode

ccxt Integration

ccxt provides a unified API across 100+ exchanges. Switching between exchanges becomes trivial.

import ccxt from 'ccxt';

const exchange = new ccxt.hyperliquid({
  walletAddress: process.env.HL_WALLET,
  privateKey: process.env.HL_PRIVATE_KEY,
});

// Fetch balance
const balance = await exchange.fetchBalance();
console.log('USDC:', balance.USDC);

// Place a limit buy
const order = await exchange.createLimitBuyOrder(
  'ETH/USDC:USDC', 0.01, 1800
);

// Fetch open orders
const open = await exchange.fetchOpenOrders('ETH/USDC:USDC');
Enter fullscreen mode Exit fullscreen mode

Why ethers.js v5 (not v6)

Hyperliquid uses EIP-712 for transaction signing. There are known issues with ethers v6 and Hyperliquid's L1 auth flow. Stick with v5.

import { ethers } from 'ethers';

const wallet = new ethers.Wallet(process.env.HL_PRIVATE_KEY);

// EIP-712 domain for L1 auth
const domain = {
  name: 'HyperliquidSignTransaction',
  version: '1',
  chainId: 1337,
  verifyingContract: '0x' + '0'.repeat(40),
};
Enter fullscreen mode Exit fullscreen mode

SQLite with WAL Mode

Trade history and bot state go into SQLite. WAL mode enables concurrent reads and writes.

import Database from 'better-sqlite3';

const db = new Database('grid-bot.db');
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');

db.exec(`
  CREATE TABLE IF NOT EXISTS trades (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    pair TEXT NOT NULL,
    side TEXT NOT NULL,
    price REAL NOT NULL,
    size REAL NOT NULL,
    pnl REAL DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);
Enter fullscreen mode Exit fullscreen mode

Why not Postgres/MySQL? For a solo bot operation, SQLite is enough. Single file backup, zero server management, and WAL handles concurrency fine.

PM2 Configuration

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: 'grid-eth',
      script: 'grid-bot.js',
      env: { COIN: 'ETH', LEVERAGE: '3' },
      max_memory_restart: '200M',
      cron_restart: '0 */6 * * *',
    },
    {
      name: 'grid-btc',
      script: 'grid-bot.js',
      env: { COIN: 'BTC', LEVERAGE: '3' },
      max_memory_restart: '200M',
      cron_restart: '0 */6 * * *',
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

PM2 is non-negotiable for 24/7 operation:

  • Auto-restart on crash
  • max_memory_restart catches memory leaks
  • cron_restart for periodic restarts
  • pm2 logs and pm2 monit for observability

Grid Bot Architecture

Core Logic

Grid width is ATR-based, not fixed. Adapts to volatility automatically.

function calculateATR(candles, period = 14) {
  const trueRanges = candles.map((c, i) => {
    if (i === 0) return c.high - c.low;
    const prev = candles[i - 1];
    return Math.max(
      c.high - c.low,
      Math.abs(c.high - prev.close),
      Math.abs(c.low - prev.close)
    );
  });
  return trueRanges.slice(-period)
    .reduce((a, b) => a + b, 0) / period;
}

function calculateGridLevels(price, gridCount, atrMult, atr) {
  const halfRange = atr * atrMult;
  const upper = price + halfRange;
  const lower = price - halfRange;
  const step = (upper - lower) / gridCount;

  const levels = [];
  for (let i = 0; i <= gridCount; i++) {
    levels.push(lower + step * i);
  }
  return { levels, upper, lower, step };
}
Enter fullscreen mode Exit fullscreen mode

Order Placement

async function placeGridOrders(exchange, pair, levels, currentPrice, size) {
  const orders = [];
  for (const level of levels) {
    if (level < currentPrice) {
      orders.push(await exchange.createLimitBuyOrder(pair, size, level));
    } else if (level > currentPrice) {
      orders.push(await exchange.createLimitSellOrder(pair, size, level));
    }
  }
  return orders;
}
Enter fullscreen mode Exit fullscreen mode

Operational Challenges

429 Rate Limiting

Running 5 pairs simultaneously hammers the API. Exponential backoff is essential.

async function rateLimitedCall(fn, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (e) {
      if (e.message?.includes('429')) {
        const wait = 2 ** i * 1000 + Math.random() * 1000;
        console.warn(`Rate limited, backing off ${Math.round(wait)}ms`);
        await new Promise(r => setTimeout(r, wait));
      } else throw e;
    }
  }
  throw new Error('Rate limit retries exhausted');
}
Enter fullscreen mode Exit fullscreen mode

The real fix: move from REST polling to WebSocket.

import WebSocket from 'ws';

const ws = new WebSocket('wss://api.hyperliquid.xyz/ws');

ws.on('open', () => {
  ws.send(JSON.stringify({
    method: 'subscribe',
    subscription: { type: 'l2Book', coin: 'ETH' },
  }));
  ws.send(JSON.stringify({
    method: 'subscribe',
    subscription: { type: 'userFills', user: walletAddress },
  }));
});

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  // Handle order book updates and fill notifications
});
Enter fullscreen mode Exit fullscreen mode

Leverage Creep

Grid bots accumulate positions at grid edges. Effective leverage can silently exceed your target.

async function checkLeverage(exchange, pair, max = 3) {
  const positions = await exchange.fetchPositions([pair]);
  const pos = positions.find(p => p.symbol === pair);
  if (pos && pos.leverage > max) {
    console.warn(`Leverage ${pos.leverage}x > max ${max}x`);
    return false; // Stop placing new orders
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

SQLite Write Contention

Multiple bot processes writing to the same DB file will conflict. Solutions:

  • Set busy_timeout = 5000 in every process
  • Or split into per-bot DB files (recommended)

VPS Issues I've Hit

  • Provider maintenance causing downtime
  • Disk full from log files (solved with PM2 log rotation)
  • Memory leaks from long-running Node.js processes (solved with cron_restart)

Project Structure

crypto-bot/
├── grid-bot/
│   ├── grid-bot.js
│   ├── ecosystem.config.cjs
│   └── grid-bot.db
├── funding-bot/
│   ├── funding-arb-cross.js
│   └── funding-arb.db
├── liquidation-ws/
│   └── hl-liquidation-ws.js
├── shared/
│   └── hl-client.js
└── package.json
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Hyperliquid is great for bot developers: low fees, solid API, on-chain transparency
  • "Set and forget" is a myth. Constant parameter tuning, bug fixing, and infra maintenance required
  • Leverage management is the #1 risk. One bad day can wipe your account
  • PM2 + SQLite WAL + WebSocket is a solid stack for solo bot operations
  • Start with $100 on testnet before going live

I wrote a more detailed article (in Japanese) about my background and how I got into bot trading here:

https://shuen-shukusai.com/media/crypto/cr-p2-hyperliquid-bot/

Top comments (0)