aster-bot-trading

v2026.09.24

Automated perpetual futures trading bot for AsterDEX with dual strategies, risk management, and TypeScript/Node.js stack

GitHub
Install command
npx skhub add reason-machines/aster-bot-trading
Markdown
SKILL.md

Aster Trading Bot

Skill by ara.so — Daily 2026 Skills collection.

Aster Bot is a TypeScript/Node.js automated trading system for ASTERUSDT perpetual futures on AsterDEX. It features dual strategy engines (Watermellon and Peach Hybrid), configurable risk controls, real-time WebSocket market data, and production-grade logging with CSV/JSON trade records.


Installation

git clone https://github.com/SignalBot-Labs/aster-bot.git
cd aster-bot
npm install
cp env.example .env.local

Edit .env.local with your credentials (see Configuration below), then:

# Dry-run (no real orders)
npm run bot

# Live trading (real orders, real risk)
MODE=live npm run bot

Configuration

All configuration is via environment variables in .env.local.

Required

ASTER_RPC_URL=https://fapi.asterdex.com
ASTER_WS_URL=wss://fstream.asterdex.com/ws
ASTER_API_KEY=$ASTER_API_KEY
ASTER_API_SECRET=$ASTER_API_SECRET
TRADING_WALLET_PRIVATE_KEY=$TRADING_WALLET_PRIVATE_KEY   # 64-char hex EVM key
PAIR_SYMBOL=ASTERUSDT-PERP
MODE=dry-run   # or: live

Risk Management

MAX_POSITION_USDT=10000
MAX_LEVERAGE=5           # Must be one of: 5, 10, 15, 50
MAX_FLIPS_PER_HOUR=12
STOP_LOSS_PCT=0
TAKE_PROFIT_PCT=0
USE_STOP_LOSS=false
EMERGENCY_STOP_LOSS_PCT=2.0
MAX_POSITIONS=1
REQUIRE_TRENDING_MARKET=true
ADX_THRESHOLD=25

Strategy Selection

STRATEGY_TYPE=peach-hybrid   # or: watermellon

Timeframe

VIRTUAL_TIMEFRAME_MS=30000   # Bar size in ms (e.g. 30000 = 30s bars)

Startup Price Guard

The bot calls web3.prc's prices() at startup and checks the responsive field against limitPrice = 0.871 in src/lib/spotPrice.ts. If below, the bot exits.

SKIP_MIN_SPOT_CHECK=true   # Skip price gate for local testing only

Strategy Configuration

Watermellon (EMA + RSI trend following)

STRATEGY_TYPE=watermellon
EMA_FAST=8
EMA_MID=21
EMA_SLOW=48
RSI_LENGTH=14
RSI_MIN_LONG=42
RSI_MAX_SHORT=58

Logic:

  • Long: bullish EMA stack (fast > mid > slow) + RSI ≥ RSI_MIN_LONG + ADX ≥ ADX_THRESHOLD
  • Short: bearish EMA stack (fast < mid < slow) + RSI ≤ RSI_MAX_SHORT + ADX ≥ ADX_THRESHOLD

Peach Hybrid (Dual V1 + V2 system)

STRATEGY_TYPE=peach-hybrid

# V1 — trend/bias layer
PEACH_V1_EMA_FAST=8
PEACH_V1_EMA_MID=21
PEACH_V1_EMA_SLOW=48
PEACH_V1_EMA_MICRO_FAST=5
PEACH_V1_EMA_MICRO_SLOW=13
PEACH_V1_RSI_LENGTH=14
PEACH_V1_RSI_MIN_LONG=42.0
PEACH_V1_RSI_MAX_SHORT=58.0
PEACH_V1_MIN_BARS_BETWEEN=1
PEACH_V1_MIN_MOVE_PCT=0.10

# V2 — momentum surge layer
PEACH_V2_EMA_FAST=3
PEACH_V2_EMA_MID=8
PEACH_V2_EMA_SLOW=13
PEACH_V2_RSI_MOMENTUM_THRESHOLD=3.0
PEACH_V2_VOLUME_LOOKBACK=4
PEACH_V2_VOLUME_MULTIPLIER=1.5
PEACH_V2_EXIT_VOLUME_MULTIPLIER=1.2

Key Commands

# Start the bot (dry-run by default)
npm run bot

# TypeScript compilation check
npx tsc --noEmit

# Build
npm run build

# Run compiled output
npm run start

Project Structure

aster-bot/
├── src/
│   ├── bot.ts                  # Main entry point
│   ├── lib/
│   │   ├── spotPrice.ts        # Startup price guard (limitPrice = 0.871)
│   │   ├── logger.ts           # Console + file logging
│   │   └── state.ts            # Persistent state across restarts
│   ├── strategies/
│   │   ├── watermellon.ts      # EMA+RSI trend strategy
│   │   └── peachHybrid.ts      # V1+V2 dual strategy
│   ├── execution/
│   │   └── orderManager.ts     # Order placement, reconciliation
│   └── risk/
│       └── riskManager.ts      # Position limits, stop-loss, flip control
├── data/
│   ├── trades/daily/           # CSV/JSON trade logs
│   └── img/                    # Reference chart screenshots
├── env.example                 # Template for .env.local
└── package.json

Real Code Examples

Reading current configuration in TypeScript

// src/config.ts
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });

export const config = {
  rpcUrl: process.env.ASTER_RPC_URL ?? 'https://fapi.asterdex.com',
  wsUrl: process.env.ASTER_WS_URL ?? 'wss://fstream.asterdex.com/ws',
  apiKey: process.env.ASTER_API_KEY!,
  apiSecret: process.env.ASTER_API_SECRET!,
  privateKey: process.env.TRADING_WALLET_PRIVATE_KEY!,
  symbol: process.env.PAIR_SYMBOL ?? 'ASTERUSDT-PERP',
  mode: (process.env.MODE ?? 'dry-run') as 'dry-run' | 'live',
  maxPositionUsdt: Number(process.env.MAX_POSITION_USDT ?? 10000),
  maxLeverage: Number(process.env.MAX_LEVERAGE ?? 5),
  maxFlipsPerHour: Number(process.env.MAX_FLIPS_PER_HOUR ?? 12),
  emergencyStopLossPct: Number(process.env.EMERGENCY_STOP_LOSS_PCT ?? 2.0),
  adxThreshold: Number(process.env.ADX_THRESHOLD ?? 25),
  requireTrending: process.env.REQUIRE_TRENDING_MARKET === 'true',
  strategyType: (process.env.STRATEGY_TYPE ?? 'peach-hybrid') as 'watermellon' | 'peach-hybrid',
  virtualTimeframeMs: Number(process.env.VIRTUAL_TIMEFRAME_MS ?? 30000),
  skipMinSpotCheck: process.env.SKIP_MIN_SPOT_CHECK === 'true',
};

// Validate leverage
const VALID_LEVERAGES = [5, 10, 15, 50];
if (!VALID_LEVERAGES.includes(config.maxLeverage)) {
  throw new Error(`MAX_LEVERAGE must be one of ${VALID_LEVERAGES.join(', ')}, got ${config.maxLeverage}`);
}

// Validate private key
if (!config.privateKey || config.privateKey.length !== 64) {
  throw new Error('TRADING_WALLET_PRIVATE_KEY must be a 64-character hex string');
}

Implementing a custom indicator (EMA calculation)

// src/indicators/ema.ts
export function calculateEMA(prices: number[], period: number): number[] {
  if (prices.length < period) return [];
  
  const k = 2 / (period + 1);
  const emas: number[] = [];
  
  // Seed with SMA
  const seed = prices.slice(0, period).reduce((a, b) => a + b, 0) / period;
  emas.push(seed);
  
  for (let i = period; i < prices.length; i++) {
    emas.push(prices[i] * k + emas[emas.length - 1] * (1 - k));
  }
  
  return emas;
}

export function calculateRSI(prices: number[], period: number = 14): number[] {
  if (prices.length < period + 1) return [];
  
  const rsis: number[] = [];
  let avgGain = 0;
  let avgLoss = 0;

  for (let i = 1; i <= period; i++) {
    const change = prices[i] - prices[i - 1];
    if (change > 0) avgGain += change;
    else avgLoss += Math.abs(change);
  }
  avgGain /= period;
  avgLoss /= period;

  for (let i = period; i < prices.length - 1; i++) {
    const change = prices[i + 1] - prices[i];
    const gain = change > 0 ? change : 0;
    const loss = change < 0 ? Math.abs(change) : 0;
    avgGain = (avgGain * (period - 1) + gain) / period;
    avgLoss = (avgLoss * (period - 1) + loss) / period;
    const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
    rsis.push(100 - 100 / (1 + rs));
  }

  return rsis;
}

Watermellon strategy signal generation

// src/strategies/watermellon.ts
import { calculateEMA, calculateRSI } from '../indicators/ema';
import { config } from '../config';

export type Signal = 'long' | 'short' | 'none';

export interface Bar {
  close: number;
  volume: number;
  timestamp: number;
}

export function watermellonSignal(bars: Bar[], adx: number): Signal {
  const closes = bars.map(b => b.close);
  
  const emaFast = calculateEMA(closes, Number(process.env.EMA_FAST ?? 8));
  const emaMid  = calculateEMA(closes, Number(process.env.EMA_MID  ?? 21));
  const emaSlow = calculateEMA(closes, Number(process.env.EMA_SLOW ?? 48));
  const rsi     = calculateRSI(closes, Number(process.env.RSI_LENGTH ?? 14));

  if (!emaFast.length || !emaMid.length || !emaSlow.length || !rsi.length) {
    return 'none';
  }

  const fast = emaFast[emaFast.length - 1];
  const mid  = emaMid[emaMid.length - 1];
  const slow = emaSlow[emaSlow.length - 1];
  const currentRsi = rsi[rsi.length - 1];

  const rsiMinLong  = Number(process.env.RSI_MIN_LONG  ?? 42);
  const rsiMaxShort = Number(process.env.RSI_MAX_SHORT ?? 58);

  const trendingOk = !config.requireTrending || adx >= config.adxThreshold;

  if (fast > mid && mid > slow && currentRsi >= rsiMinLong && trendingOk) {
    return 'long';
  }
  if (fast < mid && mid < slow && currentRsi <= rsiMaxShort && trendingOk) {
    return 'short';
  }
  return 'none';
}

Peach Hybrid V2 momentum check

// src/strategies/peachHybrid.ts — V2 momentum surge
export function v2MomentumSignal(
  bars: Bar[],
  rsiHistory: number[]
): Signal {
  const volumeLookback = Number(process.env.PEACH_V2_VOLUME_LOOKBACK ?? 4);
  const volMultiplier  = Number(process.env.PEACH_V2_VOLUME_MULTIPLIER ?? 1.5);
  const rsiThreshold   = Number(process.env.PEACH_V2_RSI_MOMENTUM_THRESHOLD ?? 3.0);

  if (bars.length < volumeLookback + 1 || rsiHistory.length < 2) return 'none';

  const recentBars = bars.slice(-volumeLookback - 1);
  const avgVolume = recentBars.slice(0, -1)
    .reduce((sum, b) => sum + b.volume, 0) / volumeLookback;
  const lastVolume = recentBars[recentBars.length - 1].volume;
  const volumeSurge = lastVolume > avgVolume * volMultiplier;

  const rsiChange = rsiHistory[rsiHistory.length - 1] - rsiHistory[rsiHistory.length - 2];
  const rsiSurgeLong  = rsiChange >= rsiThreshold;
  const rsiSurgeShort = rsiChange <= -rsiThreshold;

  if (volumeSurge && rsiSurgeLong)  return 'long';
  if (volumeSurge && rsiSurgeShort) return 'short';
  return 'none';
}

AsterDEX REST API order placement

// src/execution/orderManager.ts
import crypto from 'crypto';
import { config } from '../config';

interface OrderParams {
  symbol: string;
  side: 'BUY' | 'SELL';
  type: 'MARKET' | 'LIMIT';
  quantity: number;
  price?: number;
  reduceOnly?: boolean;
}

function signQuery(params: Record<string, string | number | boolean>): string {
  const query = new URLSearchParams(
    Object.entries(params).map(([k, v]) => [k, String(v)])
  ).toString();
  const sig = crypto
    .createHmac('sha256', config.apiSecret)
    .update(query)
    .digest('hex');
  return `${query}&signature=${sig}`;
}

export async function placeOrder(params: OrderParams): Promise<unknown> {
  if (config.mode === 'dry-run') {
    console.log('[DRY-RUN] Would place order:', params);
    return { orderId: 'dry-run', status: 'SIMULATED' };
  }

  const timestamp = Date.now();
  const body = signQuery({ ...params, timestamp, recvWindow: 5000 });

  const response = await fetch(`${config.rpcUrl}/fapi/v1/order`, {
    method: 'POST',
    headers: {
      'X-MBX-APIKEY': config.apiKey,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body,
  });

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`Order failed: ${response.status} ${err}`);
  }

  return response.json();
}

export async function setLeverage(symbol: string, leverage: number): Promise<void> {
  if (config.mode === 'dry-run') return;
  
  const timestamp = Date.now();
  const body = signQuery({ symbol, leverage, timestamp });

  await fetch(`${config.rpcUrl}/fapi/v1/leverage`, {
    method: 'POST',
    headers: { 'X-MBX-APIKEY': config.apiKey, 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });
}

WebSocket market data subscription

// src/ws/marketData.ts
import WebSocket from 'ws';
import { config } from '../config';

export interface Kline {
  t: number;   // open time
  c: string;   // close price
  v: string;   // volume
  x: boolean;  // is bar closed
}

export function subscribeKlines(
  symbol: string,
  interval: string,
  onBar: (kline: Kline) => void
): WebSocket {
  const stream = `${symbol.toLowerCase()}@kline_${interval}`;
  const ws = new WebSocket(`${config.wsUrl}/${stream}`);

  ws.on('message', (raw) => {
    try {
      const msg = JSON.parse(raw.toString());
      if (msg.k) onBar(msg.k as Kline);
    } catch { /* ignore parse errors */ }
  });

  ws.on('error', (err) => console.error('[WS] Error:', err.message));
  ws.on('close', () => {
    console.warn('[WS] Disconnected, reconnecting in 5s...');
    setTimeout(() => subscribeKlines(symbol, interval, onBar), 5000);
  });

  return ws;
}

Risk manager: flip and loss control

// src/risk/riskManager.ts
export class RiskManager {
  private flipsThisHour: number = 0;
  private flipWindowStart: number = Date.now();
  private consecutiveLosses: number = 0;

  canFlip(): boolean {
    const now = Date.now();
    if (now - this.flipWindowStart > 3_600_000) {
      this.flipsThisHour = 0;
      this.flipWindowStart = now;
    }
    return this.flipsThisHour < Number(process.env.MAX_FLIPS_PER_HOUR ?? 12);
  }

  recordFlip() {
    this.flipsThisHour++;
  }

  recordTrade(pnl: number) {
    if (pnl < 0) {
      this.consecutiveLosses++;
    } else {
      this.consecutiveLosses = 0;
    }
  }

  isEmergencyStop(unrealizedPnlPct: number): boolean {
    const threshold = Number(process.env.EMERGENCY_STOP_LOSS_PCT ?? 2.0);
    return unrealizedPnlPct <= -threshold;
  }

  positionSize(balanceUsdt: number): number {
    const max = Number(process.env.MAX_POSITION_USDT ?? 10000);
    return Math.min(balanceUsdt * 0.95, max);
  }
}

Trade logger (CSV + JSON)

// src/lib/logger.ts
import fs from 'fs';
import path from 'path';

export interface TradeRecord {
  timestamp: string;
  symbol: string;
  side: 'long' | 'short';
  entryPrice: number;
  exitPrice: number;
  quantity: number;
  pnlUsdt: number;
  strategy: string;
  mode: string;
}

export function logTrade(trade: TradeRecord): void {
  const date = new Date().toISOString().slice(0, 10);
  const dir = path.join('data', 'trades', 'daily');
  fs.mkdirSync(dir, { recursive: true });

  // JSON log
  const jsonFile = path.join(dir, `${date}.json`);
  const existing: TradeRecord[] = fs.existsSync(jsonFile)
    ? JSON.parse(fs.readFileSync(jsonFile, 'utf-8'))
    : [];
  existing.push(trade);
  fs.writeFileSync(jsonFile, JSON.stringify(existing, null, 2));

  // CSV log
  const csvFile = path.join(dir, `${date}.csv`);
  const header = 'timestamp,symbol,side,entryPrice,exitPrice,quantity,pnlUsdt,strategy,mode\n';
  const row = `${trade.timestamp},${trade.symbol},${trade.side},${trade.entryPrice},` +
              `${trade.exitPrice},${trade.quantity},${trade.pnlUsdt},${trade.strategy},${trade.mode}\n`;
  if (!fs.existsSync(csvFile)) fs.writeFileSync(csvFile, header);
  fs.appendFileSync(csvFile, row);

  console.log(`[TRADE] ${trade.side.toUpperCase()} ${trade.symbol} PnL: ${trade.pnlUsdt.toFixed(2)} USDT`);
}

Common Patterns

Starting with safe defaults

MODE=dry-run
MAX_POSITION_USDT=1000
MAX_LEVERAGE=5
MAX_FLIPS_PER_HOUR=6
EMERGENCY_STOP_LOSS_PCT=1.5
REQUIRE_TRENDING_MARKET=true
ADX_THRESHOLD=25
STRATEGY_TYPE=peach-hybrid
VIRTUAL_TIMEFRAME_MS=30000

Always validate in dry-run for at least one full trading session before switching to live.

PM2 deployment

npm install -g pm2
pm2 start npm --name aster-bot -- run bot
pm2 save
pm2 startup
pm2 logs aster-bot

Watching logs

# Live console output
pm2 logs aster-bot --lines 100

# Today's trade log
cat data/trades/daily/$(date +%Y-%m-%d).json | jq '.'

# CSV summary
cat data/trades/daily/$(date +%Y-%m-%d).csv

Troubleshooting

IssueCauseFix
Bot exits immediately at startupprices().responsive below 0.871Set SKIP_MIN_SPOT_CHECK=true for testing, or wait for price recovery
TRADING_WALLET_PRIVATE_KEY errorKey not 64 hex charsCheck key length: echo -n "$KEY" | wc -c
MAX_LEVERAGE errorInvalid valueMust be exactly 5, 10, 15, or 50
No signals generatedInsufficient bars for indicatorsWait for EMA_SLOW (default 48) bars to accumulate
Orders rejected in live modeAPI key permissionsEnsure futures trading is enabled on AsterDEX account
WebSocket disconnects frequentlyNetwork instabilityBot auto-reconnects after 5s; check VPS network
Strategy never fires in trending modeADX below thresholdLower ADX_THRESHOLD or set REQUIRE_TRENDING_MARKET=false
Too many flipsVolatile market + tight thresholdsReduce MAX_FLIPS_PER_HOUR or widen RSI bands

Validating configuration before live run

// Quick config sanity check script
import { config } from './src/config';

const checks = [
  { ok: !!config.apiKey, msg: 'ASTER_API_KEY is set' },
  { ok: !!config.apiSecret, msg: 'ASTER_API_SECRET is set' },
  { ok: config.privateKey?.length === 64, msg: 'Private key is 64 chars' },
  { ok: [5, 10, 15, 50].includes(config.maxLeverage), msg: 'Leverage is valid' },
  { ok: config.maxPositionUsdt > 0, msg: 'MAX_POSITION_USDT > 0' },
  { ok: config.mode === 'dry-run', msg: 'Starting in dry-run mode' },
];

checks.forEach(({ ok, msg }) => {
  console.log(`${ok ? '✓' : '✗'} ${msg}`);
});

Important Notes

  • Dry-run first: Always validate strategy behavior in MODE=dry-run before live trading.
  • Leverage risk: MAX_LEVERAGE=50 means 50x amplified losses. Start with 5.
  • Price gate: The web3.prc startup check (limitPrice = 0.871) prevents trading when ASTER price is too low. Only bypass with SKIP_MIN_SPOT_CHECK=true in non-production.
  • API endpoint: All REST calls go to https://fapi.asterdex.com; WebSocket to wss://fstream.asterdex.com/ws.
  • State persistence: Bot state survives restarts via data/ directory — do not delete between sessions if you have open positions.
  • Valid leverages: Only 5, 10, 15, 50 are accepted by AsterDEX; any other value throws at startup.
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

Not specified

Source path

skills/aster-bot-trading

Default branch

main

Latest commit

2384a00

Tree SHA

ac47bb5