A scalable paper trading bot for Drift Protocol (Solana), written in Node 18+ (ESM).
It cleanly separates config, markets, strategy, execution, risk, state, and orchestration so you can extend it fast and operate within RPC constraints.
⚠️ Paper only. No transactions are sent. The wallet is a dummyKeypairused for read-only subscription.
-
Modular architecture
- Markets: pick one or many via
MARKETSenv; only subscribes to those indexes. - Strategy: pluggable
emaAdaptive— bull & bear, cost/vol-aware, warmup, state machine, breakout filter. - Risk manager: daily loss cap, per-market position cap, trade throttling, cooldown after losses.
- Execution: self-contained paper broker with fees, slippage, realized/unrealized PnL.
- State: JSON persistence with per-market books and strategy seeding on restart.
- Markets: pick one or many via
-
RPC-friendly
- Subscribes only to required
perpMarketIndexes. skipLoadUsers: true, MAX_MARKETS clamp, optionalACCOUNT_SUB_TYPE=polling.- Loop tick interval + min move (bps) gate + optional jitter to reduce churn and cost.
- Subscribes only to required
-
Correct accounting
- Equity = cash + Σ(UPNL) (RPNL already flows through cash).
- NAV cross-check in logs.
drift-paperbot/
package.json
.env.example
README.md
src/
index.js # wires everything together
config.js # single source of truth for knobs (STRAT\_EMA, RISK, etc.)
logging/logger.js # timestamped logger (info/warn/error/debug?)
utils/ema.js # EMA helper(s)
utils/time.js # nowIso()
state/store.js # JSON-backed state; per-market books; strategy snapshot persistence
execution/paperBroker.js # fees, slippage, realized/unrealized PnL; returns realizedDelta on fills
risk/riskManager.js # daily loss, position caps, throttling, cooldown; equity helper
markets/resolveMarketIndex.js
markets/universe.js # parses MARKETS env, resolves to market indexes, clamps MAX\_MARKETS
drift/client.js # Connection + DriftClient subscribe; getMarkPrice()
strategies/emaAdaptive.js# adaptive EMA; bull/bear; warmup; volatility & breakout aware
bot/runBot.js # orchestrator; seeds strategy; clamps exits; applies risk; logs; autosave
npm i
cp .env.example .env
# Fill RPC_URL (and WS_URL if your provider requires a separate websocket endpoint)
npm start# --- Required ---
RPC_URL=YOUR_MAINNET_ENDPOINT
WS_URL=YOUR_WS_ENDPOINT # optional but recommended
# --- Network/Markets ---
NETWORK=mainnet-beta
MARKETS=SOL-PERP # or SOL-PERP,BTC-PERP (keep short on lower RPC tiers)
MAX_MARKETS=1 # clamp to avoid 413s on small plans
# --- Strategy (emaAdaptive) ---
BASE_NOTIONAL=100
FAST_EMA=20
SLOW_EMA=60
LONG_ONLY=false
ENTER_BPS_LONG=20
EXIT_BPS_LONG=10
ENTER_BPS_SHORT=28
EXIT_BPS_SHORT=12
MIN_HOLD_MS=120000
COOLDOWN_MS=30000
VOL_LOOKBACK=60
VOL_K=1.5
BREAKOUT_LOOKBACK=0
BREAKOUT_BPS=5
MIN_WARM_TICKS=120 # if omitted => max(2 * SLOW_EMA, 200)
# --- Bot cadence & noise gating ---
TICK_MS=5000
MIN_MARK_MOVE_BPS=2
TICK_JITTER_MS=500
# --- State ---
STATE_FILE=./state.json
RESET_STATE=false
INITIAL_DEPOSIT=10000
# --- Subscription mode ---
ACCOUNT_SUB_TYPE=websocket # or polling
# --- Risk ---
MAX_POSITION_USD_PER_MARKET=0 # 0 = unlimited
MAX_TRADES_PER_MIN=20
DAILY_LOSS_LIMIT_PCT=0 # e.g., 3 = halt new entries after -3% day
COOLDOWN_AFTER_LOSS_MS=60000- Config:
createConfig()centralizes all knobs, buildsSTRAT_EMA&RISK. - Universe:
buildMarketUniverse()parsesMARKETS, resolves each to a marketIndex, clamps toMAX_MARKETS. - Client:
createDriftContext()subscribes only to those indexes;getMarkPrice(index)returns{bid, ask, mark}. - State:
storeholds global cash/deposit and per-market: position, entry, RPNL, fees,lastMark,strategysnapshot. - Strategy: for each market we
createEMAAdaptive(cfg.STRAT_EMA)and seed it fromstore.getStrategyState(symbol). - Risk:
risk.evaluate()vets entries (always allows exits) and caps quantity. - Broker:
paperFill()applies slip & fees, updates cash & PnL; returnsrealizedDeltafor cooldown logic. - Runner:
runBot()loops byTICK_MS(+ optional jitter), gates tiny moves byMIN_MARK_MOVE_BPS, calls strategy, clamps exits to flat (no one-tick flips), appliesrisk, fills via broker, persists strategysnapshot(), autosaves. - Logs: periodic status prints px, pos, entry, UPNL/RPNL/fees, and equity=cash+UPNL with a NAV sanity check.
- Bull & Bear: decides regime from slow EMA slope; long when bullish, short when bearish (
LONG_ONLY=falseto enable shorts). - Cost aware: uses bps thresholds, widened by EWMA volatility (
VOL_K). - Churn guards:
MIN_HOLD_MS,COOLDOWN_MS. - Warmup: no signals until
MIN_WARM_TICKS(default derived fromSLOW_EMAif not provided). - Optional breakout:
BREAKOUT_LOOKBACK/BREAKOUT_BPSto confirm near highs/lows before entering.
Strategy instance is stateful and seeded from
storeto be restart-safe (EMAs, lastState, volEwmaBps, etc.).
- Daily loss limit: halts new entries if equity drops beyond
DAILY_LOSS_LIMIT_PCTvs day-start equity (tracked instate.meta). - Per-market position cap:
MAX_POSITION_USD_PER_MARKET. - Throttle: global
MAX_TRADES_PER_MIN. - Cooldown after loss: pauses entries for
COOLDOWN_AFTER_LOSS_MSafter a trade realizing a loss. - Always allow exits to flat (risk never traps positions).
- Fees/Slippage: 2 bps fee + 1 bp slip (per side) — configurable in code if you want.
- PnL math: realized on partial closes and flips; VWAP entry maintained.
- Returns
{ trade, realizedDelta }so risk can trigger cooldowns on losing fills.
- Per-market status:
px≈…, pos=…, entry=…, UPNL=…, RPNL=…, fees=… - Equity line:
equity = cash + Σ(UPNL)(RPNL already in cash). - NAV cross-check:
alt = deposit + RPNL + UPNL – feesTotal(warn if mismatch).
Lower-tier endpoints (e.g., QuickNode Discover) may reject large getMultipleAccounts calls:
- Subscribe minimally: only the indexes in your universe.
- Clamp:
MAX_MARKETS=1(raise cautiously). skipLoadUsers: true: we don’t read user maps in paper mode.ACCOUNT_SUB_TYPE=polling: fallback if WS costs or limits are painful.- Cadence: increase
TICK_MS, useMIN_MARK_MOVE_BPS, optionallyTICK_JITTER_MS.
- More strategies: drop a new file in
src/strategies/that exportscreateStrategy(cfg) -> { onPrice, snapshot? }. Instantiate per market inindex.js. - Per-market overrides: add
STRAT_EMA_BY_SYMBOLtoconfig.jsand merge with the baseSTRAT_EMAbefore creating each strategy. - Backtesting (TODO): add a
feeds/CSV reader and ascripts/backtest-ema.jsto reuse the same strategy + broker offline. - Funding bias (nice-to-have): plumb funding rates into the strategy context and require positive funding for shorts, etc.
You can add scripts like:
{
"scripts": {
"start": "node src/index.js",
"dev": "NODE_ENV=development node src/index.js",
"lint": "eslint .",
"prettier": "prettier -w ."
}
}(If you add a markets lister or backtester later, wire them here.)
Core
RPC_URL(required),WS_URL(optional),NETWORK(defaultmainnet-beta)
Markets
MARKETS(comma-sep symbols likeSOL-PERP,BTC-PERP)MAX_MARKETS(safety clamp)
Strategy (emaAdaptive)
BASE_NOTIONAL,FAST_EMA,SLOW_EMA,LONG_ONLYENTER_BPS_LONG,EXIT_BPS_LONG,ENTER_BPS_SHORT,EXIT_BPS_SHORTMIN_HOLD_MS,COOLDOWN_MSVOL_LOOKBACK,VOL_KBREAKOUT_LOOKBACK,BREAKOUT_BPSMIN_WARM_TICKS
Runner & Noise Gating
TICK_MS,MIN_MARK_MOVE_BPS,TICK_JITTER_MS
State
STATE_FILE,RESET_STATE,INITIAL_DEPOSIT
Subscription
ACCOUNT_SUB_TYPE=websocket|polling
Risk
MAX_POSITION_USD_PER_MARKETMAX_TRADES_PER_MINDAILY_LOSS_LIMIT_PCTCOOLDOWN_AFTER_LOSS_MS
- ✔️ No live orders; no keys required for trading.
- ✔️ Deterministic, restart-safe indicators via persisted snapshots.
- ❌ No real PnL; fees/slippage are modelled.
- ❌ Not a profit promise; use backtests before risking capital.
- Equity seems off → confirm it’s
cash + UPNLonly; RPNL already flows throughcash. - Churn/fees high → raise
ENTER_BPS_*, lengthen EMAs, increaseMIN_HOLD_MS, or enable breakout filter. - RPC errors 410/413 → reduce
MAX_MARKETS; trypolling; increaseTICK_MS; verify WS endpoint; consider a Solana-focused RPC.
MIT. Use at your own risk.