Secure paper-mode backend for a personal Robinhood Crypto trading assistant.
Live trading is disabled by default and is not yet implemented. The Robinhood client functions are stubs that throw NOT_IMPLEMENTED so nothing can accidentally place a real order until Ed25519 request signing is wired and tested.
BOT_ENABLED=falseby default — every trade request is rejected.PAPER_MODE=trueby default — when the bot is enabled, trades are simulated and logged, never sent to Robinhood.- The Robinhood client never returns fake "live" data. Calls in live mode return a clean
501 NOT_IMPLEMENTEDuntil signing is implemented. - Risk gates: per-trade USD cap, daily loss cap, allowed-symbol whitelist, minimum confidence, idempotency on
recommendationId. - Bearer-token auth on every non-health endpoint, with constant-time comparison.
- Strict CORS (
FRONTEND_URLonly). CORS alone does not protect non-browser clients — the bearer token does. - API keys are never logged (pino redaction list).
cd ~/Desktop/crypto-trading-backend
npm install
cp .env.example .env
# Generate a strong bearer token:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Paste it into BACKEND_API_KEY in .env
npm run devThe server listens on http://localhost:3001 by default. Hit /health to confirm it's up — it's the only public route.
- Push this directory to a private GitHub repo.
- Create a new Railway service from that repo.
- Set every variable from
.env.examplein Railway's dashboard. Never commit.env. - Mount a Railway volume at
/app/dataso the SQLite database persists across deploys (Railway's default filesystem can be ephemeral). - Set
DATA_DIR=/app/datain the Railway env. - Set
FRONTEND_URLto your Lovable frontend's exact origin (no trailing slash, no wildcard). - Deploy. Railway sets
PORTautomatically.
| Var | Purpose | Default | Required for live? |
|---|---|---|---|
PORT |
HTTP port | 3001 |
always |
FRONTEND_URL |
Single allowed CORS origin | http://localhost:5173 |
always |
BACKEND_API_KEY |
Shared bearer token | (none) | always |
BOT_ENABLED |
Master kill switch | false |
— |
PAPER_MODE |
Simulate vs send to Robinhood | true |
— |
MAX_TRADE_USD |
Per-trade USD cap | 25 |
always |
MAX_DAILY_LOSS_USD |
Circuit breaker on realized loss | 50 |
always |
ALLOWED_SYMBOLS |
Whitelist (CSV) | BTC-USD,ETH-USD |
always |
MIN_CONFIDENCE |
Min confidenceScore to accept |
0.5 |
always |
ROBINHOOD_API_KEY |
Robinhood API key | (blank) | live mode |
ROBINHOOD_PRIVATE_KEY |
Ed25519 private key (base64) | (blank) | live mode |
DATA_DIR |
SQLite directory | ./data |
always |
LOG_LEVEL |
pino level | info |
always |
The server refuses to start if BACKEND_API_KEY is missing/short, or if BOT_ENABLED=true && PAPER_MODE=false while Robinhood credentials are blank.
Every request except /health must include the bearer token:
const API = import.meta.env.VITE_BACKEND_URL; // e.g. https://crypto-bot.up.railway.app
const KEY = import.meta.env.VITE_BACKEND_API_KEY; // matches BACKEND_API_KEY on the server
async function approve(rec) {
const res = await fetch(`${API}/trade/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
recommendationId: rec.id,
symbol: rec.symbol, // 'BTC-USD' | 'ETH-USD'
side: rec.side, // 'buy' | 'sell'
suggestedAmountUsd: rec.amountUsd,
confidenceScore: rec.confidence, // 0.0 - 1.0
entryReason: rec.entryReason,
stopLoss: rec.stopLoss,
profitTarget: rec.profitTarget,
invalidationLevel: rec.invalidationLevel,
riskReward: rec.riskReward,
}),
});
return res.json();
}Security note: the Lovable frontend will be embedding
BACKEND_API_KEYinto client-side bundles. Anyone with browser devtools can read it. That is acceptable for a single-user personal tool, because the only thing the token gates is paper trades and (eventually) trades capped atMAX_TRADE_USD. For multi-user use, replace the bearer token with per-user JWTs and re-think the auth layer.
Every response is JSON:
Stable rejection codes: BOT_DISABLED, MISSING_FIELDS, INVALID_SIDE, SYMBOL_NOT_ALLOWED, AMOUNT_OUT_OF_RANGE, RISK_FIELDS_INVALID, CONFIDENCE_TOO_LOW, DAILY_LOSS_CAP_HIT, DUPLICATE_RECOMMENDATION, INVALID_BODY, UNAUTHENTICATED, RATE_LIMITED, NOT_IMPLEMENTED, INTERNAL_ERROR.
BOT_ENABLED |
PAPER_MODE |
/trade/approve behavior |
|---|---|---|
false |
any | Reject with BOT_DISABLED. Master kill switch. |
true |
true |
Simulate the trade, log it, return status: "simulated". No call to Robinhood. |
true |
false |
Calls robinhoodClient.placeOrder(). Currently returns 501 NOT_IMPLEMENTED until signing is wired. |
/trade/decline and decision logging are unaffected by these flags.
Do not skip steps.
- Implement Ed25519 request signing in
src/services/robinhoodClient.jsper the official Robinhood Crypto API docs and replace theNotImplementedErrorthrows. - Test signed requests against a Robinhood sandbox or read-only endpoint first. Confirm headers, signature canonicalization, and clock skew handling.
- Run at least 50 paper trades through
/trade/approveand review the rows indata/trading.db— every field should match the recommendation. - Force a daily-loss-cap scenario by inserting losing rows directly into
tradesand confirmDAILY_LOSS_CAP_HITfires. - Set
MAX_TRADE_USDto the smallest amount you can stand to lose on a single trade. - Review
ALLOWED_SYMBOLS. Anything outside this list is silently rejected. - Back up
data/trading.db. - Test the kill switch: while a
npm startinstance is running, setBOT_ENABLED=falsein Railway and restart. Confirm new approve requests getBOT_DISABLED. - Set
PAPER_MODE=falseonly in Railway's env vars — never in any committed file. Commit aPAPER_MODE=truevalue to.env.exampleso a fresh checkout is safe by default.
| Method | Path | Auth |
|---|---|---|
| GET | /health |
public |
| GET | /account |
bearer |
| POST | /trade/approve |
bearer (rate-limited) |
| POST | /trade/decline |
bearer (rate-limited) |
| GET | /trades?limit=N&status=...&mode=... |
bearer |
| GET | /performance |
bearer |
| GET | /weekly-report |
bearer |
npm testCovers every rejection code in riskManager.evaluate plus a supertest pass over each route.
src/
├── server.js
├── config.js
├── db.js
├── middleware/ auth, validate, errorHandler
├── routes/ health, account, trade, trades, performance, weeklyReport
└── services/ robinhoodClient (stubs), riskManager, tradeLogger, logger
data/ gitignored; SQLite lives here
tests/ jest + supertest