Compare commits
No commits in common. "532169d7487ca8d1d08ebbf2afb0bced3d61745a" and "8929d4cd6e6cd7dc26d2d58dfff42347ead3542c" have entirely different histories.
532169d748
...
8929d4cd6e
13 changed files with 50 additions and 311 deletions
6
.env
Normal file
6
.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
PAIRS=BTC-USD,ETH-USD,XRP-USD,BTC-EUR,ETH-EUR,CNYUSD
|
||||
INTERVAL=5000
|
||||
THRESHOLD=0.05
|
||||
POSTGRES_USER=uphold
|
||||
POSTGRES_PASSWORD=uphold
|
||||
POSTGRES_DB=uphold_events
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
PAIRS=BTC-USD,CNYUSD
|
||||
INTERVAL=5000
|
||||
THRESHOLD=0.05
|
||||
POSTGRES_USER=uphold
|
||||
POSTGRES_PASSWORD=uphold
|
||||
POSTGRES_DB=uphold_db
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
|||
node_modules/
|
||||
package-lock.json
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
FROM docker.io/node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
# RUN npm ci --omit=dev
|
||||
RUN npm ci
|
||||
COPY src/ ./src/
|
||||
COPY index.js ./
|
||||
|
||||
RUN addgroup -g 1001 uphold && adduser -S -G uphold -u 1001 uphold
|
||||
USER uphold
|
||||
RUN addgroup -g 1001 nodejs && adduser -S -G nodejs -u 1001 nodejs
|
||||
USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["node", "index.js"]
|
||||
|
|
|
|||
158
README.md
158
README.md
|
|
@ -1,158 +0,0 @@
|
|||
# Uphold currency rate bot for interview
|
||||
## Requirements
|
||||
|
||||
- Node.js >= v20
|
||||
- Docker and Docker Compose (optionally, Podman and Podman compose)
|
||||
- PostgreSQL (if running outside of Docker)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Run the Bot
|
||||
|
||||
**With default parameters, no DB or API:**
|
||||
```bash
|
||||
node index.js
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
**With custom parameters:**
|
||||
```bash
|
||||
node index.js --pairs BTC-USD,ETH-USD --interval 10000 --threshold 0.05
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
npm start -- --pairs BTC-USD,ETH-USD --interval 10000 --threshold 0.01
|
||||
```
|
||||
|
||||
**With environment file:**
|
||||
```bash
|
||||
node --env-file=.env.example index.js
|
||||
```
|
||||
|
||||
### 3. Run with Docker/Podman Compose (includes DB and API)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
````
|
||||
|
||||
Edit .env with your settings:
|
||||
|
||||
```
|
||||
POSTGRES_USER=uphold
|
||||
POSTGRES_PASSWORD=uphold
|
||||
POSTGRES_DB=uphold_db
|
||||
PAIRS=BTC-USD,ETH-USD
|
||||
INTERVAL=5000
|
||||
THRESHOLD=0.01
|
||||
```
|
||||
|
||||
```
|
||||
# Start services
|
||||
docker compose up -d --build
|
||||
|
||||
```
|
||||
or
|
||||
|
||||
```
|
||||
podman compose up -d --build
|
||||
```
|
||||
|
||||
# View logs
|
||||
|
||||
```
|
||||
docker compose logs -f uphold_bot_1
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
podman compose logs -f uphold_bot_1
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | Flag | Environment | Default | Description |
|
||||
|-----------|------|-------------|---------|-------------|
|
||||
| Pairs | `-p, --pairs` | `PAIRS` | `BTC-USD` | Comma-separated currency pairs |
|
||||
| Interval | `-i, --interval` | `INTERVAL` | `5000` | Uphold API retrieval interval in milliseconds |
|
||||
| Threshold | `-t, --threshold` | `THRESHOLD` | `0.01` | Alert difference percentage |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## API
|
||||
### Endpoints
|
||||
|
||||
**Health Check**
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
**Statistics**
|
||||
```bash
|
||||
curl http://localhost:3000/stats
|
||||
```
|
||||
|
||||
**Alerts**
|
||||
```bash
|
||||
# Get last 50 alerts (default)
|
||||
curl http://localhost:3000/alerts
|
||||
|
||||
# Get last 100 alerts
|
||||
curl http://localhost:3000/alerts?limit=100
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
[18:44:51.898] INFO: Bot starting...
|
||||
[18:44:51.898] INFO: [WATCHING] BTC-USD | Every 5000ms | Threshold 0.01%
|
||||
[18:44:51.898] WARN: [DB] specificiation missing. Running in memor
|
||||
[18:44:52.167] INFO: [BTC-USD] Monitoring every 5000ms, +/-0.01%
|
||||
[18:44:52.367] INFO: [BTC-USD] Initial price: 90977.65
|
||||
[18:45:07.879] INFO: [ALERT] BTC-USD DOWN -0.0116%
|
||||
pair: "BTC-USD"
|
||||
direction: "DOWN"
|
||||
change: "-0.0116%"
|
||||
prev: "90977.648894"
|
||||
curr: "90967.085674"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
- `index.js` - Entry point
|
||||
- `src/bot.js` - Bot logic
|
||||
- `src/api.js` - Uphold API client
|
||||
- `src/db.js` - DB logic
|
||||
- `src/logger.js` - Simple logging
|
||||
- `src/server.js` - API server for docker diagnosis
|
||||
- `src/stats.js` - Statistics tracking
|
||||
- `tests/bot.test.js` - Tests
|
||||
|
||||
## Stopping the Bot
|
||||
|
||||
Press `Ctrl+C` or send `SIGTERM` for graceful shutdown.
|
||||
|
||||
With Docker:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
or Podman
|
||||
```bash
|
||||
podman compose down
|
||||
podman volume rm uphold_pgdata
|
||||
```
|
||||
11
compose.yml
11
compose.yml
|
|
@ -1,21 +1,16 @@
|
|||
services:
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
build: .
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
- PAIRS=${PAIRS:-BTC-USD}
|
||||
- INTERVAL=${INTERVAL:-5000}
|
||||
- THRESHOLD=${THRESHOLD:-0.01}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "http://localhost:3000/health"]
|
||||
test: ["CMD", "node", "-e", "require('node:process').exit(require('fs').existsSync('/tmp/healthy') ? 0 : 1)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
@ -26,7 +21,7 @@ services:
|
|||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-uphold} -d ${POSTGRES_DB:-uphold_db}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U uphold -d uphold_alerts"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
|
|
|||
16
index.js
16
index.js
|
|
@ -1,9 +1,9 @@
|
|||
import fs from "node:fs";
|
||||
import { parseArgs } from "node:util";
|
||||
import { prefetchRates } from "./src/api.js";
|
||||
import { Bot } from "./src/bot.js";
|
||||
import logger from "./src/logger.js";
|
||||
import { initDB, insertIntoDB, closePool } from "./src/db.js";
|
||||
import { startServer } from "./src/server.js";
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.fatal(err, "Uncaught exception. Application crashing...");
|
||||
|
|
@ -31,6 +31,11 @@ const options = {
|
|||
short: "t",
|
||||
default: process.env.THRESHOLD || "0.01",
|
||||
},
|
||||
stats: {
|
||||
type: "boolean",
|
||||
default: process.env.STATS === "true",
|
||||
description: "Show performance stats periodically",
|
||||
},
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
|
|
@ -45,7 +50,7 @@ const interval = parseInt(values.interval, 10);
|
|||
const threshold = parseFloat(values.threshold);
|
||||
|
||||
if (isNaN(interval) || interval < 100) {
|
||||
logger.error("Interval must be a number larger than 100");
|
||||
logger.error("Interval must be a number larger than 100ms");
|
||||
process.exit(1);
|
||||
}
|
||||
if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
|
||||
|
|
@ -58,21 +63,22 @@ if (threshold <= 0 || threshold > 100) {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
logger.info("Bot starting...");
|
||||
logger.info("Uphold price alert bot starting...");
|
||||
logger.info(
|
||||
`[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`,
|
||||
);
|
||||
|
||||
await initDB();
|
||||
startServer();
|
||||
|
||||
try {
|
||||
await prefetchRates(pairs);
|
||||
} catch (err) {
|
||||
logger.error(err, "Critical failure during cache population");
|
||||
logger.error(err, "Critical failure during cache warming");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.promises.writeFile("/tmp/healthy", "ok");
|
||||
|
||||
const handleAlert = async (alertData) => {
|
||||
await insertIntoDB(alertData);
|
||||
};
|
||||
|
|
|
|||
57
src/api.js
57
src/api.js
|
|
@ -1,60 +1,49 @@
|
|||
import { BigNumber } from "bignumber.js";
|
||||
import PQueue from "p-queue";
|
||||
import logger from "./logger.js";
|
||||
import { incrementReq } from "./stats.js";
|
||||
|
||||
const queue = new PQueue({ interval: 1000, intervalCap: 10 });
|
||||
|
||||
export async function fetchRate(pair) {
|
||||
const key = pair.toUpperCase();
|
||||
const max_retries = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= max_retries; attempt++) {
|
||||
let timeout;
|
||||
try {
|
||||
const res = await queue.add(async () => {
|
||||
return queue.add(async () => {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
return fetch(`https://api.uphold.com/v0/ticker/${key}`, {
|
||||
const res = await fetch(`https://api.uphold.com/v0/ticker/${key}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 429) {
|
||||
incrementReq("ratelimit");
|
||||
throw new Error("Rate limited");
|
||||
if (res.status === 429) throw new Error("Rate limited");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.ask || isNaN(parseFloat(data.ask))) {
|
||||
throw new Error("Invalid ask price");
|
||||
}
|
||||
|
||||
return new BigNumber(data.ask);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
if (attempt === 3) throw err;
|
||||
await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
incrementReq("fail");
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
incrementReq("success");
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.ask || isNaN(parseFloat(data.ask))) {
|
||||
throw new Error("Invalid ask price");
|
||||
}
|
||||
|
||||
return new BigNumber(data.ask);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
if (attempt === 3) throw err;
|
||||
await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function prefetchRates(pairs) {
|
||||
logger.info({ pairs }, "Verifying pairs...");
|
||||
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
|
||||
|
||||
const failed = results.filter((r) => r.status === "rejected");
|
||||
if (failed.length > 0) {
|
||||
logger.warn(`[API] ${failed.length} pairs failed initial check`);
|
||||
logger.warn(`[API] ${failed.length} pairs failed initial check.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { fetchRate } from "./api.js";
|
||||
import logger from "./logger.js";
|
||||
import { updatePair, incrementAlerts } from "./stats.js";
|
||||
|
||||
export class Bot {
|
||||
constructor(config, onAlert) {
|
||||
|
|
@ -33,20 +32,16 @@ export class Bot {
|
|||
try {
|
||||
const price = await fetchRate(this.pair);
|
||||
|
||||
updatePair(this.pair, price.toFixed(6));
|
||||
|
||||
if (!this.lastPrice) {
|
||||
this.lastPrice = price;
|
||||
logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`);
|
||||
} else {
|
||||
// Using BigNumber
|
||||
const change = price
|
||||
.minus(this.lastPrice)
|
||||
.dividedBy(this.lastPrice)
|
||||
.multipliedBy(100);
|
||||
|
||||
if (change.abs().gte(this.threshold)) {
|
||||
incrementAlerts();
|
||||
const dir = change.gt(0) ? "UP" : "DOWN";
|
||||
logger.info(
|
||||
{
|
||||
|
|
@ -70,7 +65,6 @@ export class Bot {
|
|||
};
|
||||
|
||||
try {
|
||||
// onAlert must be fast
|
||||
await this.onAlert(alertData);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
|
|
|
|||
17
src/db.js
17
src/db.js
|
|
@ -24,7 +24,7 @@ export function getPool() {
|
|||
});
|
||||
|
||||
pool.on("error", (err) => {
|
||||
logger.error(err, "[DB] Unexpected error on client", err);
|
||||
logger.error(err, "[DB] Unexpected error on idle client", err);
|
||||
});
|
||||
|
||||
return pool;
|
||||
|
|
@ -59,7 +59,7 @@ export async function initDB() {
|
|||
try {
|
||||
client = await currentPool.connect();
|
||||
await client.query(CREATE_TABLE_QUERY);
|
||||
logger.info("[DB] Database initialized and connected");
|
||||
logger.info("[DB] Database initialized and connected.");
|
||||
return;
|
||||
} catch (err) {
|
||||
if (i === maxRetries - 1) {
|
||||
|
|
@ -106,22 +106,11 @@ export async function insertIntoDB(data) {
|
|||
await getPool().query(query, values);
|
||||
logger.info(`[DB] Event saved for ${data.pair}`);
|
||||
} catch (err) {
|
||||
logger.error(`[DB] Failed to save alert ${err.message}`);
|
||||
logger.error(`[DB] Failed to save alert: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlerts(limit = 50) {
|
||||
if (!pool) {
|
||||
return [];
|
||||
}
|
||||
const res = await pool.query(
|
||||
"SELECT * FROM alerts ORDER BY created_at DESC LIMIT $1",
|
||||
[limit],
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import http from "node:http";
|
||||
import { URL } from "node:url";
|
||||
import { stats } from "./stats.js";
|
||||
import { getAlerts } from "./db.js";
|
||||
import logger from "./logger.js";
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
const sendJSON = (res, data, status = 200) => {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const { pathname, searchParams } = parsedUrl;
|
||||
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
return sendJSON(res, { status: "ok", uptime: process.uptime() });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/stats") {
|
||||
return sendJSON(res, stats);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/alerts") {
|
||||
const limit = parseInt(searchParams.get("limit")) || 50;
|
||||
const alerts = await getAlerts(limit);
|
||||
return sendJSON(res, { count: alerts.length, data: alerts });
|
||||
}
|
||||
|
||||
sendJSON(res, { error: "Not Found" }, 404);
|
||||
} catch (err) {
|
||||
logger.error(err, "API Error");
|
||||
sendJSON(res, { error: "Internal Server Error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export const startServer = () => {
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`[API] Server listening on port ${PORT}`);
|
||||
});
|
||||
return server;
|
||||
};
|
||||
31
src/stats.js
31
src/stats.js
|
|
@ -1,31 +0,0 @@
|
|||
export const stats = {
|
||||
startedAt: new Date().toISOString(),
|
||||
api: {
|
||||
totalRequests: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
rateLimited: 0,
|
||||
},
|
||||
bot: {
|
||||
totalAlerts: 0,
|
||||
activePairs: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const incrementReq = (type) => {
|
||||
stats.api.totalRequests++;
|
||||
if (type === "success") stats.api.successful++;
|
||||
if (type === "fail") stats.api.failed++;
|
||||
if (type === "ratelimit") stats.api.rateLimited++;
|
||||
};
|
||||
|
||||
export const updatePair = (pair, price) => {
|
||||
stats.bot.activePairs[pair] = {
|
||||
currentPrice: price,
|
||||
lastUpdate: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const incrementAlerts = () => {
|
||||
stats.bot.totalAlerts++;
|
||||
};
|
||||
|
|
@ -128,6 +128,7 @@ describe("API", () => {
|
|||
vi.resetModules();
|
||||
nock.cleanAll();
|
||||
|
||||
// Import the actual module for these tests
|
||||
vi.doMock("../src/api.js", async () => {
|
||||
return await vi.importActual("../src/api.js");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue