Compare commits
10 commits
8929d4cd6e
...
532169d748
| Author | SHA1 | Date | |
|---|---|---|---|
| 532169d748 | |||
| 0a28a20de7 | |||
| d00a359db7 | |||
| 93ee527084 | |||
| 565c2ffe26 | |||
| 08afa6452f | |||
| 13dcd1ff52 | |||
| 4a08a64dc9 | |||
| a7a7e2058c | |||
| 5d2710350e |
13 changed files with 311 additions and 50 deletions
6
.env
6
.env
|
|
@ -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
6
.env.example
Normal 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
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.env
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
FROM docker.io/node:20-alpine
|
FROM docker.io/node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# RUN npm ci --omit=dev
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY index.js ./
|
COPY index.js ./
|
||||||
|
|
||||||
RUN addgroup -g 1001 nodejs && adduser -S -G nodejs -u 1001 nodejs
|
RUN addgroup -g 1001 uphold && adduser -S -G uphold -u 1001 uphold
|
||||||
USER nodejs
|
USER uphold
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
ENTRYPOINT ["node", "index.js"]
|
ENTRYPOINT ["node", "index.js"]
|
||||||
|
|
|
||||||
158
README.md
158
README.md
|
|
@ -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
|
||||||
|
```
|
||||||
11
compose.yml
11
compose.yml
|
|
@ -1,16 +1,21 @@
|
||||||
services:
|
services:
|
||||||
bot:
|
bot:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Containerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
- PORT=3000
|
||||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
- PAIRS=${PAIRS:-BTC-USD}
|
- PAIRS=${PAIRS:-BTC-USD}
|
||||||
- INTERVAL=${INTERVAL:-5000}
|
- INTERVAL=${INTERVAL:-5000}
|
||||||
- THRESHOLD=${THRESHOLD:-0.01}
|
- THRESHOLD=${THRESHOLD:-0.01}
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -21,7 +26,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
|
||||||
16
index.js
16
index.js
|
|
@ -1,9 +1,9 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import { prefetchRates } from "./src/api.js";
|
import { prefetchRates } from "./src/api.js";
|
||||||
import { Bot } from "./src/bot.js";
|
import { Bot } from "./src/bot.js";
|
||||||
import logger from "./src/logger.js";
|
import logger from "./src/logger.js";
|
||||||
import { initDB, insertIntoDB, closePool } from "./src/db.js";
|
import { initDB, insertIntoDB, closePool } from "./src/db.js";
|
||||||
|
import { startServer } from "./src/server.js";
|
||||||
|
|
||||||
process.on("uncaughtException", (err) => {
|
process.on("uncaughtException", (err) => {
|
||||||
logger.fatal(err, "Uncaught exception. Application crashing...");
|
logger.fatal(err, "Uncaught exception. Application crashing...");
|
||||||
|
|
@ -31,11 +31,6 @@ const options = {
|
||||||
short: "t",
|
short: "t",
|
||||||
default: process.env.THRESHOLD || "0.01",
|
default: process.env.THRESHOLD || "0.01",
|
||||||
},
|
},
|
||||||
stats: {
|
|
||||||
type: "boolean",
|
|
||||||
default: process.env.STATS === "true",
|
|
||||||
description: "Show performance stats periodically",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
|
|
@ -50,7 +45,7 @@ const interval = parseInt(values.interval, 10);
|
||||||
const threshold = parseFloat(values.threshold);
|
const threshold = parseFloat(values.threshold);
|
||||||
|
|
||||||
if (isNaN(interval) || interval < 100) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
|
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() {
|
async function main() {
|
||||||
logger.info("Uphold price alert bot starting...");
|
logger.info("Bot starting...");
|
||||||
logger.info(
|
logger.info(
|
||||||
`[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`,
|
`[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await initDB();
|
await initDB();
|
||||||
|
startServer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prefetchRates(pairs);
|
await prefetchRates(pairs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err, "Critical failure during cache warming");
|
logger.error(err, "Critical failure during cache population");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.promises.writeFile("/tmp/healthy", "ok");
|
|
||||||
|
|
||||||
const handleAlert = async (alertData) => {
|
const handleAlert = async (alertData) => {
|
||||||
await insertIntoDB(alertData);
|
await insertIntoDB(alertData);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
57
src/api.js
57
src/api.js
|
|
@ -1,49 +1,60 @@
|
||||||
import { BigNumber } from "bignumber.js";
|
import { BigNumber } from "bignumber.js";
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
import logger from "./logger.js";
|
import logger from "./logger.js";
|
||||||
|
import { incrementReq } from "./stats.js";
|
||||||
|
|
||||||
const queue = new PQueue({ interval: 1000, intervalCap: 10 });
|
const queue = new PQueue({ interval: 1000, intervalCap: 10 });
|
||||||
|
|
||||||
export async function fetchRate(pair) {
|
export async function fetchRate(pair) {
|
||||||
const key = pair.toUpperCase();
|
const key = pair.toUpperCase();
|
||||||
|
const max_retries = 3;
|
||||||
|
|
||||||
return queue.add(async () => {
|
for (let attempt = 1; attempt <= max_retries; attempt++) {
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
let timeout;
|
||||||
try {
|
try {
|
||||||
|
const res = await queue.add(async () => {
|
||||||
const controller = new AbortController();
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (res.status === 429) throw new Error("Rate limited");
|
if (res.status === 429) {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
incrementReq("ratelimit");
|
||||||
|
throw new Error("Rate limited");
|
||||||
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) {
|
export async function prefetchRates(pairs) {
|
||||||
logger.info({ pairs }, "Verifying pairs...");
|
|
||||||
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
|
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
|
||||||
|
|
||||||
const failed = results.filter((r) => r.status === "rejected");
|
const failed = results.filter((r) => r.status === "rejected");
|
||||||
if (failed.length > 0) {
|
if (failed.length > 0) {
|
||||||
logger.warn(`[API] ${failed.length} pairs failed initial check.`);
|
logger.warn(`[API] ${failed.length} pairs failed initial check`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { fetchRate } from "./api.js";
|
import { fetchRate } from "./api.js";
|
||||||
import logger from "./logger.js";
|
import logger from "./logger.js";
|
||||||
|
import { updatePair, incrementAlerts } from "./stats.js";
|
||||||
|
|
||||||
export class Bot {
|
export class Bot {
|
||||||
constructor(config, onAlert) {
|
constructor(config, onAlert) {
|
||||||
|
|
@ -32,16 +33,20 @@ export class Bot {
|
||||||
try {
|
try {
|
||||||
const price = await fetchRate(this.pair);
|
const price = await fetchRate(this.pair);
|
||||||
|
|
||||||
|
updatePair(this.pair, price.toFixed(6));
|
||||||
|
|
||||||
if (!this.lastPrice) {
|
if (!this.lastPrice) {
|
||||||
this.lastPrice = price;
|
this.lastPrice = price;
|
||||||
logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`);
|
logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`);
|
||||||
} else {
|
} else {
|
||||||
|
// Using BigNumber
|
||||||
const change = price
|
const change = price
|
||||||
.minus(this.lastPrice)
|
.minus(this.lastPrice)
|
||||||
.dividedBy(this.lastPrice)
|
.dividedBy(this.lastPrice)
|
||||||
.multipliedBy(100);
|
.multipliedBy(100);
|
||||||
|
|
||||||
if (change.abs().gte(this.threshold)) {
|
if (change.abs().gte(this.threshold)) {
|
||||||
|
incrementAlerts();
|
||||||
const dir = change.gt(0) ? "UP" : "DOWN";
|
const dir = change.gt(0) ? "UP" : "DOWN";
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
|
|
@ -65,6 +70,7 @@ export class Bot {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// onAlert must be fast
|
||||||
await this.onAlert(alertData);
|
await this.onAlert(alertData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
17
src/db.js
17
src/db.js
|
|
@ -24,7 +24,7 @@ export function getPool() {
|
||||||
});
|
});
|
||||||
|
|
||||||
pool.on("error", (err) => {
|
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;
|
return pool;
|
||||||
|
|
@ -59,7 +59,7 @@ export async function initDB() {
|
||||||
try {
|
try {
|
||||||
client = await currentPool.connect();
|
client = await currentPool.connect();
|
||||||
await client.query(CREATE_TABLE_QUERY);
|
await client.query(CREATE_TABLE_QUERY);
|
||||||
logger.info("[DB] Database initialized and connected.");
|
logger.info("[DB] Database initialized and connected");
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (i === maxRetries - 1) {
|
if (i === maxRetries - 1) {
|
||||||
|
|
@ -106,11 +106,22 @@ export async function insertIntoDB(data) {
|
||||||
await getPool().query(query, values);
|
await getPool().query(query, values);
|
||||||
logger.info(`[DB] Event saved for ${data.pair}`);
|
logger.info(`[DB] Event saved for ${data.pair}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[DB] Failed to save alert: ${err.message}`);
|
logger.error(`[DB] Failed to save alert ${err.message}`);
|
||||||
throw err;
|
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() {
|
export async function closePool() {
|
||||||
if (pool) {
|
if (pool) {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|
|
||||||
45
src/server.js
Normal file
45
src/server.js
Normal 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
31
src/stats.js
Normal 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++;
|
||||||
|
};
|
||||||
|
|
@ -128,7 +128,6 @@ describe("API", () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
nock.cleanAll();
|
nock.cleanAll();
|
||||||
|
|
||||||
// Import the actual module for these tests
|
|
||||||
vi.doMock("../src/api.js", async () => {
|
vi.doMock("../src/api.js", async () => {
|
||||||
return await vi.importActual("../src/api.js");
|
return await vi.importActual("../src/api.js");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue