Compare commits

..

10 commits

13 changed files with 311 additions and 50 deletions

6
.env
View file

@ -1,6 +0,0 @@
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

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
PAIRS=BTC-USD,CNYUSD
INTERVAL=5000
THRESHOLD=0.05
POSTGRES_USER=uphold
POSTGRES_PASSWORD=uphold
POSTGRES_DB=uphold_db

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/
package-lock.json
.env

View file

@ -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 nodejs && adduser -S -G nodejs -u 1001 nodejs
USER nodejs
RUN addgroup -g 1001 uphold && adduser -S -G uphold -u 1001 uphold
USER uphold
EXPOSE 3000
ENTRYPOINT ["node", "index.js"]

158
README.md
View file

@ -0,0 +1,158 @@
# 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
```

View file

@ -1,16 +1,21 @@
services:
bot:
build: .
build:
context: .
dockerfile: Containerfile
ports:
- "3000:3000"
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", "node", "-e", "require('node:process').exit(require('fs').existsSync('/tmp/healthy') ? 0 : 1)"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
@ -21,7 +26,7 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U uphold -d uphold_alerts"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-uphold} -d ${POSTGRES_DB:-uphold_db}"]
interval: 5s
timeout: 5s
retries: 5

View file

@ -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,11 +31,6 @@ 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({
@ -50,7 +45,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 100ms");
logger.error("Interval must be a number larger than 100");
process.exit(1);
}
if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
@ -63,22 +58,21 @@ if (threshold <= 0 || threshold > 100) {
}
async function main() {
logger.info("Uphold price alert bot starting...");
logger.info("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 warming");
logger.error(err, "Critical failure during cache population");
process.exit(1);
}
fs.promises.writeFile("/tmp/healthy", "ok");
const handleAlert = async (alertData) => {
await insertIntoDB(alertData);
};

View file

@ -1,26 +1,39 @@
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;
return queue.add(async () => {
for (let attempt = 1; attempt <= 3; attempt++) {
for (let attempt = 1; attempt <= max_retries; attempt++) {
let timeout;
try {
const res = await queue.add(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`https://api.uphold.com/v0/ticker/${key}`, {
return fetch(`https://api.uphold.com/v0/ticker/${key}`, {
signal: controller.signal,
});
});
clearTimeout(timeout);
if (res.status === 429) throw new Error("Rate limited");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (res.status === 429) {
incrementReq("ratelimit");
throw new Error("Rate limited");
}
if (!res.ok) {
incrementReq("fail");
throw new Error(`HTTP ${res.status}`);
}
incrementReq("success");
const data = await res.json();
@ -35,15 +48,13 @@ export async function fetchRate(pair) {
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`);
}
}

View file

@ -1,5 +1,6 @@
import { fetchRate } from "./api.js";
import logger from "./logger.js";
import { updatePair, incrementAlerts } from "./stats.js";
export class Bot {
constructor(config, onAlert) {
@ -32,16 +33,20 @@ 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(
{
@ -65,6 +70,7 @@ export class Bot {
};
try {
// onAlert must be fast
await this.onAlert(alertData);
} catch (err) {
logger.error(

View file

@ -24,7 +24,7 @@ export function getPool() {
});
pool.on("error", (err) => {
logger.error(err, "[DB] Unexpected error on idle client", err);
logger.error(err, "[DB] Unexpected error on 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,11 +106,22 @@ 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();

45
src/server.js Normal file
View file

@ -0,0 +1,45 @@
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 Normal file
View file

@ -0,0 +1,31 @@
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++;
};

View file

@ -128,7 +128,6 @@ 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");
});