A non-custodial perp-trading agent for SOL-PERP on Drift. Paper-mode by default; multiple safety gates required before any real transaction can be sent.
No funds at risk in the default configuration. The agent simulates every trade in memory until you explicitly enable live execution — and even then it refuses to touch mainnet without a separate acknowledgment env var. See Modes.
# 1. Install (one-time)
npm install
# 2. Run the agent in PAPER mode (default — no network calls to Drift, no wallet needed)
npm startThe dashboard is at http://localhost:3000. The agent loop logs to stdout.
To stop the agent: write a KILL flag file or hit the Kill button on the dashboard.
npm run kill # macOS / Linux: touches ./KILL
npm run unkill # remove the flag and resume on the next loop tickThe agent has four reachable modes, each requiring strictly more env-var consent than the last. The default is Paper-only.
| Mode | PAPER_TRADING |
PERP_DATA_SOURCE |
PERP_LIVE_EXECUTION |
PERP_DRY_RUN |
PERP_DRIFT_ENV |
What happens |
|---|---|---|---|---|---|---|
| Paper-only (default) | true |
jupiter |
false |
– | – | Sim runs against Jupiter spot price + a static funding constant. Zero contact with Drift. No wallet needed. |
| Paper + Drift reads | true |
drift |
false |
– | devnet |
Sim runs against real Drift mark + Drift's hourly funding rate. Read-only Drift connection — never sends a tx. Needs the perp keypair to subscribe. |
| Dry-run | false |
drift |
true |
true |
devnet |
Builds every order tx including SL+TP brackets, passes every safety gate, but never broadcasts. Synthetic tx sigs only. Useful for end-to-end validation against a real Drift connection without sending anything. |
| Live (devnet) | false |
drift |
true |
false |
devnet |
Real txs broadcast to Drift devnet. Devnet has no real-world value — devnet USDC and SOL are free play money — but the txs are real. |
| Live (mainnet) | false |
drift |
true |
false |
mainnet-beta + I_UNDERSTAND_MAINNET=true |
Real money. Refuses to start without the explicit acknowledgment. |
You can flip any of these by editing .env (see .env.example).
Defense in depth. Setting PAPER_TRADING=false is something you might do for many reasons (LP, spot if it existed, integration testing). Sending real perp transactions requires also setting PERP_LIVE_EXECUTION=true. Two switches, two intentional decisions.
If you stay in Paper-only mode you do not need any of this. Skip to Run the tests.
For Paper+Drift, Dry-run, or Live modes, the agent needs a dedicated perp wallet and (for live) a Drift user account.
# 1. Generate a fresh perp keypair (creates wallet-perp-devnet.json — DEVNET ONLY)
npx ts-node scripts/generatePerpWallet.ts
# 2. (Live only) Airdrop devnet SOL for tx fees
solana airdrop 2 <pubkey-from-step-1> --url devnet
# or use https://faucet.solana.com
# 3. (Live only) Initialize the Drift user account on devnet
npx ts-node scripts/setupDriftDevnet.ts
# 4. (Live only) Get devnet USDC and deposit it as collateral
# via Drift's UI: https://app.drift.trade/?cluster=devnetThe keypair lives in wallet-perp-devnet.json (gitignored). Alternatively set PERP_WALLET_PRIVATE_KEY (base58) in .env.
Before flipping live execution on, run the dry-run smoke test against real Drift devnet. It exercises the full open + bracket + close flow without broadcasting anything:
PERP_LIVE_EXECUTION=true PAPER_TRADING=false \
npx ts-node scripts/smokeTestPerpExecutor.ts(The --live flag actually sends devnet txs. Don't pass it until you're sure.)
| Control | Where | Notes |
|---|---|---|
| Max leverage | perpRiskManager.check | PERP_MAX_LEVERAGE (default 2x) |
| Max position size | perpRiskManager | PERP_MAX_POSITION_USD (default $200) |
| Mandatory stop-loss | perpRiskManager + perpExecutor.openWithBracket | Atomic open+SL+TP — never naked |
| Liquidation buffer | perpRiskManager | SL must be ≥PERP_LIQUIDATION_BUFFER_PCT (default 15%) of entry away from liquidation price |
| Funding-cost gate | perpSimulator.checkFundingGate | Auto-close if adverse funding exceeds PERP_FUNDING_CLOSE_THRESHOLD_HOURLY |
| Stale-oracle gate | driftClient.getOracleStalenessMs | Skip the tick if Drift's oracle hasn't updated in PERP_MAX_ORACLE_STALENESS_MS (default 30s) |
| Live-execution gate | perpExecutor.checkLiveGate | PERP_LIVE_EXECUTION=true AND !PAPER_TRADING AND (devnet OR mainnet-acknowledged) |
| Mainnet acknowledgment | boot + checkLiveGate |
PERP_DRIFT_ENV=mainnet-beta requires I_UNDERSTAND_MAINNET=true |
| Dry-run override | perpExecutor | PERP_DRY_RUN=true builds every tx but never broadcasts — synthetic sigs |
| Wallet isolation | loadPerpKeypair | Perp agent uses its own keypair file. Do not share it with any other wallet. |
| Kill switch | perpTrader.runLoop + killSwitchFlush | Flag file ./KILL halts the loop. In live mode, also cancels orders + flattens position once per kill cycle. |
npm testRuns 14 test scripts (~2 minutes). Mocks every network dep — no Drift, Jupiter, Ollama, or Solana RPC calls during the suite.
npm run backtest:perp -- --days=90 --leverage=2Backtests the strategy against cached CoinGecko data.
See .env.example for every env var with default and inline doc.
The ones that matter most:
PAPER_TRADING—trueis the safe defaultPERP_DATA_SOURCE—jupiter(default) for offline-friendly paper,driftfor live dataPERP_LIVE_EXECUTION— required to send real txsPERP_DRY_RUN— overrides live execution into a "build but never broadcast" modeENABLE_PERP— agent on/off (defaulttrue)
src/
├── agents/perpTrader.ts # the loop
├── skills/
│ ├── perpSimulator.ts # paper accounting + auto-close logic
│ ├── perpStrategy.ts # rule-based momentum decision
│ ├── perpComposite.ts # rule + LLM blender
│ ├── perpReasonerLlm.ts # Ollama wrapper
│ ├── perpRiskManager.ts # pre-trade hard gate
│ ├── perpExecutor.ts # Drift live-trade surface (or dry-run)
│ ├── driftClient.ts # @drift-labs/sdk wrapper
│ ├── marketDataFetcher.ts # Jupiter price (paper-mode fallback)
│ ├── priceHistory.ts # rolling pct-change window
│ ├── killSwitch.ts # ./KILL flag check
│ ├── wallet.ts # perp keypair loader
│ ├── retry.ts # backoff for transient HTTP errors
│ ├── historicalData.ts # CoinGecko cache for backtests
│ └── logger.ts # console + Telegram
├── config.ts # env → AgentConfig
├── types.ts # type defs
├── state.ts # dashboard event bus
├── server.ts # Express dashboard server
└── index.ts # boot
scripts/
├── runAllTests.ts # `npm test`
├── backtestPerp.ts # `npm run backtest:perp`
├── generatePerpWallet.ts # creates wallet-perp-devnet.json
├── setupDriftDevnet.ts # one-time Drift account init
├── smokeTestDrift.ts # read-only Drift devnet smoke test
├── smokeTestPerpExecutor.ts # Phase 3c dry-run/live executor smoke test
├── postinstallDriftStub.js # Windows shim for helius-laserstream
└── test*.ts, _*.ts # the 14 test scripts + helpers
public/ # dashboard frontend (Chart.js + vanilla JS)
PLAN.md # design doc (read this for the why)
Tracked in PLAN.md §5 (phases) and §11 (TODOs):
- Phase 3c — dry-run script is built; the
--liveround-trip on devnet is unrun. - Phase 4 — operational soak (≥1 week on devnet) hasn't happened.
- Tx confirmation —
placeOrdersreturns a sig but the executor doesn'tawait connection.confirmTransaction. The next loop's reconcile catches the failure mode but it's reactive. - Compute budget / priority fees — not configured. May matter on busy mainnet.
- Retry on Drift writes — transient RPC failures during open propagate as exceptions; loop's outer catch logs and skips. Fine for now.
- Real-fill price reconcile — sim records
entryPrice = oraclePriceat decision time; Drift's auction may fill at a different price. Phase 3c will surface how big the gap is.