Better error handling, caching and deduplication. Added tests to api and db logic
This commit is contained in:
parent
f4de1c7bc2
commit
628d7ade76
9 changed files with 510 additions and 226 deletions
5
.env
5
.env
|
|
@ -1,3 +1,6 @@
|
||||||
PAIRS=BTC-USD,ETH-USD,XRP-USD
|
PAIRS=BTC-USD,ETH-USD,XRP-USD,BTC-EUR,ETH-EUR,CNYUSD
|
||||||
INTERVAL=5000
|
INTERVAL=5000
|
||||||
THRESHOLD=0.05
|
THRESHOLD=0.05
|
||||||
|
POSTGRES_USER=uphold
|
||||||
|
POSTGRES_PASSWORD=uphold
|
||||||
|
POSTGRES_DB=uphold_events
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY index.js ./
|
||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN addgroup -g 1001 nodejs && adduser -S -G nodejs -u 1001 nodejs
|
||||||
|
USER nodejs
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENTRYPOINT ["node", "index.js"]
|
ENTRYPOINT ["node", "index.js"]
|
||||||
CMD ["--pairs", "BTC-USD"]
|
|
||||||
|
|
|
||||||
16
compose.yml
16
compose.yml
|
|
@ -5,17 +5,19 @@ services:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://uphold:uphold_password@db:5432/uphold_alerts
|
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
- PAIRS=${PAIRS:-BTC-USD,ETH-USD}
|
- PAIRS=${PAIRS:-BTC-USD}
|
||||||
- INTERVAL=${INTERVAL:-5000}
|
- INTERVAL=${INTERVAL:-5000}
|
||||||
- THRESHOLD=${THRESHOLD:-0.01}
|
- THRESHOLD=${THRESHOLD:-0.01}
|
||||||
command: ["--pairs", "${PAIRS:-BTC-USD,ETH-USD}", "--interval", "${INTERVAL:-5000}", "--threshold", "${THRESHOLD:-0.01}"]
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('node:process').exit(require('fs').existsSync('/tmp/healthy') ? 0 : 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: docker.io/postgres:15-alpine
|
image: docker.io/postgres:15-alpine
|
||||||
environment:
|
env_file: ".env"
|
||||||
- POSTGRES_USER=uphold
|
|
||||||
- POSTGRES_PASSWORD=uphold_password
|
|
||||||
- POSTGRES_DB=uphold_alerts
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
81
index.js
81
index.js
|
|
@ -1,22 +1,39 @@
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
import { prefetchRates } from "./src/api.js";
|
||||||
import { Bot } from "./src/bot.js";
|
import { Bot } from "./src/bot.js";
|
||||||
import { initDB } from "./src/db.js";
|
import logger from "./src/logger.js";
|
||||||
|
import { initDB, insertIntoDB, closePool } from "./src/db.js";
|
||||||
|
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
logger.fatal(err, "Uncaught exception. Application crashing...");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
logger.fatal(reason, "Unhandled promise Rejection");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
pairs: {
|
pairs: {
|
||||||
type: "string",
|
type: "string",
|
||||||
short: "p",
|
short: "p",
|
||||||
default: "BTC-USD",
|
default: process.env.PAIRS || "BTC-USD",
|
||||||
},
|
},
|
||||||
interval: {
|
interval: {
|
||||||
type: "string",
|
type: "string",
|
||||||
short: "i",
|
short: "i",
|
||||||
default: "5000",
|
default: process.env.INTERVAL || "5000",
|
||||||
},
|
},
|
||||||
threshold: {
|
threshold: {
|
||||||
type: "string",
|
type: "string",
|
||||||
short: "t",
|
short: "t",
|
||||||
default: "0.01",
|
default: process.env.THRESHOLD || "0.01",
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
type: "boolean",
|
||||||
|
default: process.env.STATS === "true",
|
||||||
|
description: "Show performance stats periodically",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,36 +48,58 @@ const pairs = values.pairs.split(",").map((p) => p.trim());
|
||||||
const interval = parseInt(values.interval, 10);
|
const interval = parseInt(values.interval, 10);
|
||||||
const threshold = parseFloat(values.threshold);
|
const threshold = parseFloat(values.threshold);
|
||||||
|
|
||||||
// Basic validation
|
if (isNaN(interval) || interval < 100) {
|
||||||
if (isNaN(interval) || interval < 1000) {
|
logger.error("Interval must be a number larger than 100ms");
|
||||||
console.error("Error: Interval must be a number >= 1000ms");
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
|
||||||
|
logger.error("Invalid pair format (expected BTC-USD or CNYUSD)");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function main() {
|
||||||
console.log("Starting Uphold interview bot...");
|
logger.info("Uphold price alert bot starting...");
|
||||||
console.log(
|
logger.info(
|
||||||
`Configuration: Pairs=[${pairs.join(", ")}] Interval=${interval}ms Threshold=${threshold}%`,
|
`[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.DATABASE_URL) {
|
|
||||||
await initDB();
|
await initDB();
|
||||||
} else {
|
|
||||||
console.warn("[Warning] No DATABASE_URL found. Alerts will NOT be saved.");
|
try {
|
||||||
|
await prefetchRates(pairs);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err, "Critical failure during cache warming");
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bots = pairs.map((pair) => new Bot(pair, interval, threshold));
|
const handleAlert = async (alertData) => {
|
||||||
|
await insertIntoDB(alertData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bots = pairs.map((pair) => {
|
||||||
|
return new Bot(
|
||||||
|
{
|
||||||
|
pair,
|
||||||
|
interval,
|
||||||
|
threshold,
|
||||||
|
},
|
||||||
|
handleAlert,
|
||||||
|
);
|
||||||
|
});
|
||||||
bots.forEach((b) => b.start());
|
bots.forEach((b) => b.start());
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
const shutdown = async () => {
|
||||||
console.log("\nShutting down...");
|
logger.info("Shutting down...");
|
||||||
bots.forEach((bot) => bot.stop());
|
bots.forEach((b) => b.stop());
|
||||||
|
await closePool();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("Fatal Error:", err);
|
logger.fatal(err, "Fatal error in main loop");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,14 @@
|
||||||
"description": "Technical project for Backend Engineer role at Uphold",
|
"description": "Technical project for Backend Engineer role at Uphold",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bignumber.js": "^9.3.1",
|
"bignumber.js": "^9.3.1",
|
||||||
"pg": "^8.16.3"
|
"p-queue": "^9.0.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"pino": "^10.1.0",
|
||||||
|
"quick-lru": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nock": "^14.0.10",
|
"nock": "^14.0.10",
|
||||||
|
"pino-pretty": "^13.1.2",
|
||||||
"vitest": "^4.0.14"
|
"vitest": "^4.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
src/api.js
93
src/api.js
|
|
@ -1,16 +1,93 @@
|
||||||
import { BigNumber } from "bignumber.js";
|
import { BigNumber } from "bignumber.js";
|
||||||
|
import PQueue from "p-queue";
|
||||||
|
import logger from "./logger.js";
|
||||||
|
|
||||||
export async function fetchRate(pair) {
|
const cache = new Map();
|
||||||
const url = `https://api.uphold.com/v0/ticker/${pair}`;
|
const inflight = new Map();
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
const CACHE_TTL = 4500;
|
||||||
throw new Error(`Response status ${response.status}`);
|
|
||||||
|
const queue = new PQueue({ interval: 1000, intervalCap: 15 });
|
||||||
|
|
||||||
|
export async function fetchRate(pair, forceRefresh = false) {
|
||||||
|
const key = pair.toUpperCase();
|
||||||
|
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (entry && Date.now() - entry.ts < CACHE_TTL) {
|
||||||
|
return entry.price;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
if (inflight.has(key)) {
|
||||||
|
return inflight.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskPromise = queue.add(async () => {
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
|
|
||||||
|
const res = await 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (!data.ask || isNaN(parseFloat(data.ask))) {
|
if (!data.ask || isNaN(parseFloat(data.ask))) {
|
||||||
throw new Error("Invalid data format");
|
throw new Error("Invalid ask price");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BigNumber(data.ask);
|
const price = new BigNumber(data.ask);
|
||||||
|
cache.set(key, { price, ts: Date.now() });
|
||||||
|
return price;
|
||||||
|
} catch (err) {
|
||||||
|
const isLastAttempt = attempt === 3;
|
||||||
|
|
||||||
|
if (!isLastAttempt) {
|
||||||
|
logger.warn(
|
||||||
|
{ pair: key, attempt, error: err.message },
|
||||||
|
"Fetch failed, retrying...",
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((r) =>
|
||||||
|
setTimeout(r, 1000 * Math.pow(2, attempt - 1)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inflight.set(key, taskPromise);
|
||||||
|
|
||||||
|
taskPromise
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return taskPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prefetchRates(pairs) {
|
||||||
|
logger.info({ pairs }, "Populating cache...");
|
||||||
|
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
|
||||||
|
const failed = results.filter((r) => r.status === "rejected");
|
||||||
|
if (failed.length > 0) {
|
||||||
|
logger.error(
|
||||||
|
{ count: failed.length },
|
||||||
|
"Some currencies failed to prefetch, have to wati for loop",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
119
src/bot.js
119
src/bot.js
|
|
@ -1,82 +1,87 @@
|
||||||
import { fetchRate } from "./api.js";
|
import { fetchRate } from "./api.js";
|
||||||
import { insertIntoDB } from "./db.js";
|
import logger from "./logger.js";
|
||||||
|
|
||||||
export class Bot {
|
export class Bot {
|
||||||
constructor(pair, interval, threshold) {
|
constructor(config, onAlert) {
|
||||||
this.pair = pair;
|
this.pair = config.pair;
|
||||||
this.interval = interval;
|
this.interval = config.interval;
|
||||||
this.threshold = threshold;
|
this.threshold = config.threshold;
|
||||||
|
this.onAlert = onAlert || (async () => {});
|
||||||
this.lastPrice = null;
|
this.lastPrice = null;
|
||||||
this.running = null;
|
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
|
this.running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (!this.running) {
|
if (this.running) return;
|
||||||
this.running = true;
|
this.running = true;
|
||||||
|
logger.info(
|
||||||
console.log(`[STARTED] Monitoring ${this.pair}`);
|
`[${this.pair}] Monitoring started (${this.interval}ms, +/-${this.threshold}%)`,
|
||||||
|
|
||||||
this.check().catch((err) =>
|
|
||||||
console.error(
|
|
||||||
`[ERROR] Initialization failed for ${this.pair}: ${err.message}`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
this.loop();
|
||||||
this.timer = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await this.check();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[ERROR] ${this.pair}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}, this.interval);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.isRunning = false;
|
if (!this.running) return;
|
||||||
if (this.timer) {
|
this.running = false;
|
||||||
clearInterval(this.timer);
|
if (this.timer) clearTimeout(this.timer);
|
||||||
}
|
logger.info(`[${this.pair}] Stopped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async check() {
|
loop = async () => {
|
||||||
const currentPrice = await fetchRate(this.pair);
|
try {
|
||||||
|
const price = await fetchRate(this.pair);
|
||||||
|
|
||||||
if (!this.lastPrice) {
|
if (!this.lastPrice) {
|
||||||
this.lastPrice = currentPrice;
|
this.lastPrice = price;
|
||||||
console.log(`[INIT] ${this.pair} set to ${currentPrice.toFixed(2)}`);
|
logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`);
|
||||||
return;
|
} else {
|
||||||
}
|
const change = price
|
||||||
|
.minus(this.lastPrice)
|
||||||
|
.dividedBy(this.lastPrice)
|
||||||
|
.multipliedBy(100);
|
||||||
|
|
||||||
const diff = currentPrice.minus(this.lastPrice);
|
if (change.abs().gte(this.threshold)) {
|
||||||
const percentDiff = diff.dividedBy(this.lastPrice).multipliedBy(100);
|
const dir = change.gt(0) ? "UP" : "DOWN";
|
||||||
|
logger.info(
|
||||||
if (percentDiff.abs().gte(this.threshold)) {
|
{
|
||||||
const direction = diff.isPositive() ? "UP" : "DOWN";
|
|
||||||
const priceStr = currentPrice.toFixed(2);
|
|
||||||
const prevPriceStr = this.lastPrice.toFixed(2);
|
|
||||||
const pctNum = percentDiff.toNumber();
|
|
||||||
|
|
||||||
this.alert(direction, priceStr, pctNum);
|
|
||||||
insertIntoDB({
|
|
||||||
pair: this.pair,
|
pair: this.pair,
|
||||||
direction,
|
direction: dir,
|
||||||
previousPrice: prevPriceStr,
|
change: `${change.toFixed(4)}%`,
|
||||||
newPrice: priceStr,
|
prev: this.lastPrice.toFixed(6),
|
||||||
percentChange: pctNum,
|
curr: price.toFixed(6),
|
||||||
|
},
|
||||||
|
`[ALERT] ${this.pair} ${dir} ${change.toFixed(4)}%`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertData = {
|
||||||
|
pair: this.pair,
|
||||||
|
direction: dir,
|
||||||
|
previousPrice: this.lastPrice.toFixed(6),
|
||||||
|
newPrice: price.toFixed(6),
|
||||||
|
percentChange: change.toFixed(4),
|
||||||
interval: this.interval,
|
interval: this.interval,
|
||||||
threshold: this.threshold,
|
threshold: this.threshold,
|
||||||
});
|
};
|
||||||
|
|
||||||
this.lastPrice = currentPrice;
|
try {
|
||||||
}
|
await this.onAlert(alertData);
|
||||||
}
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
alert(direction, priceStr, percentChange) {
|
err,
|
||||||
const sign = direction === "UP" ? "+" : "-";
|
`[${this.pair}] Failed to process alert callback: ${err.message}`,
|
||||||
console.log(
|
|
||||||
`[ALERT] ${this.pair} ${direction} ${sign}${percentChange.toFixed(2)}% | Price: ${priceStr}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.lastPrice = price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err, `[${this.pair}] ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.running) {
|
||||||
|
this.timer = setTimeout(this.loop, this.interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
src/db.js
78
src/db.js
|
|
@ -1,12 +1,35 @@
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
import { setTimeout } from "timers/promises";
|
import { setTimeout } from "timers/promises";
|
||||||
|
import logger from "./logger.js";
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
const pool = new Pool({
|
let pool = null;
|
||||||
|
|
||||||
|
export function getPool() {
|
||||||
|
if (pool) {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = new Pool({
|
||||||
connectionString: process.env.DATABASE_URL,
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
allowExitOnIdle: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
logger.error(err, "[DB] Unexpected error on idle client", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
const CREATE_TABLE_QUERY = `
|
const CREATE_TABLE_QUERY = `
|
||||||
CREATE TABLE IF NOT EXISTS alerts (
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
@ -22,28 +45,47 @@ const CREATE_TABLE_QUERY = `
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function initDB() {
|
export async function initDB() {
|
||||||
|
const currentPool = getPool();
|
||||||
|
if (!currentPool) {
|
||||||
|
logger.warn("[DB] specificiation missing. Running in memory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maxRetries = 10;
|
const maxRetries = 10;
|
||||||
const retryDelay = 2000;
|
let delay = 2000;
|
||||||
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
let client;
|
||||||
try {
|
try {
|
||||||
await pool.query(CREATE_TABLE_QUERY);
|
client = await currentPool.connect();
|
||||||
console.log("[DB] Database initialized and connected.");
|
await client.query(CREATE_TABLE_QUERY);
|
||||||
|
logger.info("[DB] Database initialized and connected.");
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (i === maxRetries - 1) {
|
if (i === maxRetries - 1) {
|
||||||
console.error("[DB] Failed to connect after all retries:", err.message);
|
logger.error(
|
||||||
throw err;
|
`[DB] Could not connect: ${err.message}. Continuing without DB.`,
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[DB] Connection attempt ${i + 1} failed, retrying in ${retryDelay}ms...`,
|
|
||||||
);
|
);
|
||||||
await setTimeout(retryDelay);
|
// Lets not try that again
|
||||||
|
pool = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
{ attempt: i + 1, error: err.message },
|
||||||
|
`[DB] Connection failed (${i + 1}/${maxRetries}): ${err.message}`,
|
||||||
|
);
|
||||||
|
await setTimeout(delay);
|
||||||
|
delay *= 2;
|
||||||
|
} finally {
|
||||||
|
if (client) client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertIntoDB(data) {
|
export async function insertIntoDB(data) {
|
||||||
|
const currentPool = getPool();
|
||||||
|
if (!currentPool) return;
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO alerts (
|
INSERT INTO alerts (
|
||||||
pair, direction, previous_price, new_price, percent_change,
|
pair, direction, previous_price, new_price, percent_change,
|
||||||
|
|
@ -62,9 +104,19 @@ export async function insertIntoDB(data) {
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(query, values);
|
await getPool().query(query, values);
|
||||||
console.log(`[DB] Event saved for ${data.pair}`);
|
logger.info(`[DB] Event saved for ${data.pair}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[DB] Failed to save alert: ${err.message}`);
|
// Re-throw so the caller knows there was a failure
|
||||||
|
logger.error(`[DB] Failed to save alert: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool() {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
logger.info("[DB] Connection closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,151 +1,253 @@
|
||||||
import {
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
describe,
|
|
||||||
it,
|
|
||||||
expect,
|
|
||||||
vi,
|
|
||||||
beforeEach,
|
|
||||||
afterEach,
|
|
||||||
beforeAll,
|
|
||||||
} from "vitest";
|
|
||||||
import nock from "nock";
|
import nock from "nock";
|
||||||
import { BigNumber } from "bignumber.js";
|
import { BigNumber } from "bignumber.js";
|
||||||
import { Bot } from "../src/bot.js";
|
|
||||||
|
|
||||||
vi.mock("console", () => ({
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../src/db.js", () => ({
|
vi.mock("../src/db.js", () => ({
|
||||||
insertIntoDB: vi.fn(),
|
insertIntoDB: vi.fn().mockResolvedValue(undefined),
|
||||||
initDB: vi.fn(),
|
initDB: vi.fn().mockResolvedValue(undefined),
|
||||||
|
closePool: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Test Suite", () => {
|
vi.mock("../src/logger.js", () => ({
|
||||||
const PAIR = "BTC-USD";
|
default: {
|
||||||
const INTERVAL = 100;
|
info: vi.fn(),
|
||||||
const THRESHOLD = 0.01;
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Bot Core Functionality", () => {
|
||||||
|
const CONFIG = {
|
||||||
|
pair: "BTC-USD",
|
||||||
|
interval: 100,
|
||||||
|
threshold: 0.01,
|
||||||
|
};
|
||||||
|
|
||||||
|
let BotClass;
|
||||||
let bot;
|
let bot;
|
||||||
|
let fetchRateMock;
|
||||||
|
let onAlertSpy;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeEach(async () => {
|
||||||
// Ensure BigNumber config matches what we expect
|
vi.resetModules();
|
||||||
BigNumber.config({
|
|
||||||
DECIMAL_PLACES: 10,
|
|
||||||
ROUNDING_MODE: BigNumber.ROUND_HALF_UP,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
fetchRateMock = vi.fn();
|
||||||
bot = new Bot(PAIR, INTERVAL, THRESHOLD);
|
vi.doMock("../src/api.js", () => ({
|
||||||
vi.useFakeTimers();
|
fetchRate: fetchRateMock,
|
||||||
|
prefetchRates: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const botModule = await import("../src/bot.js");
|
||||||
|
BotClass = botModule.Bot;
|
||||||
|
|
||||||
|
onAlertSpy = vi.fn();
|
||||||
|
bot = new BotClass(CONFIG, onAlertSpy);
|
||||||
|
bot.running = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
bot.stop();
|
vi.clearAllMocks();
|
||||||
vi.runAllTimers();
|
|
||||||
vi.useRealTimers();
|
|
||||||
nock.cleanAll();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Initialization and first fetch", () => {
|
describe("Initialization", () => {
|
||||||
it("should set lastPrice on first successful API call", async () => {
|
it("should initialize with correct parameters", () => {
|
||||||
nock("https://api.uphold.com")
|
expect(bot.pair).toBe(CONFIG.pair);
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
expect(bot.interval).toBe(CONFIG.interval);
|
||||||
.reply(200, { ask: "60000.50", bid: "59990.00", currency: "USD" });
|
expect(bot.threshold).toBe(CONFIG.threshold);
|
||||||
|
expect(bot.running).toBe(false);
|
||||||
|
expect(bot.lastPrice).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
await bot.check();
|
it("should set baseline price on first check without alerting", async () => {
|
||||||
|
fetchRateMock.mockResolvedValue(new BigNumber("60000.50"));
|
||||||
|
|
||||||
|
await bot.loop();
|
||||||
|
|
||||||
expect(bot.lastPrice).toBeDefined();
|
expect(bot.lastPrice).toBeDefined();
|
||||||
expect(bot.lastPrice.toFixed(2)).toBe("60000.50");
|
expect(bot.lastPrice.toFixed(2)).toBe("60000.50");
|
||||||
|
expect(onAlertSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Price change detection", () => {
|
describe("Threshold Detection", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Establish baseline
|
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
|
||||||
nock("https://api.uphold.com")
|
await bot.loop();
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
fetchRateMock.mockReset();
|
||||||
.reply(200, { ask: "50000.00" });
|
|
||||||
await bot.check();
|
|
||||||
nock.cleanAll();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT alert when change is below threshold", async () => {
|
it("should NOT alert when change is below threshold", async () => {
|
||||||
const alertSpy = vi.spyOn(bot, "alert");
|
fetchRateMock.mockResolvedValue(new BigNumber("50004.99"));
|
||||||
nock("https://api.uphold.com")
|
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
|
||||||
.reply(200, { ask: "50004.99" }); // +0.00998%
|
|
||||||
|
|
||||||
await bot.check();
|
await bot.loop();
|
||||||
|
|
||||||
expect(alertSpy).not.toHaveBeenCalled();
|
expect(onAlertSpy).not.toHaveBeenCalled();
|
||||||
|
expect(bot.lastPrice.toFixed(2)).toBe("50000.00");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should alert when price moves exactly 0.01% (threshold)", async () => {
|
it("should alert when price moves exactly at threshold", async () => {
|
||||||
const alertSpy = vi.spyOn(bot, "alert");
|
fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
|
||||||
nock("https://api.uphold.com")
|
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
|
||||||
.reply(200, { ask: "50005.00" });
|
|
||||||
|
|
||||||
await bot.check();
|
await bot.loop();
|
||||||
|
|
||||||
// Check specifically for arguments passed to alert
|
expect(onAlertSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(alertSpy).toHaveBeenCalledWith("UP", "50005.00", 0.01);
|
const alertData = onAlertSpy.mock.calls[0][0];
|
||||||
|
|
||||||
|
expect(alertData).toMatchObject({
|
||||||
|
pair: "BTC-USD",
|
||||||
|
direction: "UP",
|
||||||
|
previousPrice: "50000.000000",
|
||||||
|
newPrice: "50005.000000",
|
||||||
|
});
|
||||||
|
expect(bot.lastPrice.toFixed(2)).toBe("50005.00");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should alert on downward move", async () => {
|
it("should alert on downward price movement", async () => {
|
||||||
const alertSpy = vi.spyOn(bot, "alert");
|
fetchRateMock.mockResolvedValue(new BigNumber("49995.00"));
|
||||||
nock("https://api.uphold.com")
|
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
|
||||||
.reply(200, { ask: "49500.00" }); // -1%
|
|
||||||
|
|
||||||
await bot.check();
|
await bot.loop();
|
||||||
|
|
||||||
expect(alertSpy).toHaveBeenCalledWith("DOWN", "49500.00", -1);
|
expect(onAlertSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAlertSpy.mock.calls[0][0].direction).toBe("DOWN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should alert on large price swing", async () => {
|
||||||
|
fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
|
||||||
|
|
||||||
|
await bot.loop();
|
||||||
|
|
||||||
|
expect(onAlertSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAlertSpy.mock.calls[0][0].percentChange).toBe("2.0000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Alert method", () => {
|
describe("Consecutive Alerts Logic", () => {
|
||||||
it("should log correct message for price increase", () => {
|
it("should base next alert on last ALERT price, not last CHECKED price", async () => {
|
||||||
bot.lastPrice = new BigNumber("50000");
|
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
|
||||||
const consoleSpy = vi.spyOn(console, "log");
|
await bot.loop();
|
||||||
|
|
||||||
bot.alert("UP", "50500.00", 1);
|
fetchRateMock.mockResolvedValue(new BigNumber("50003.00"));
|
||||||
|
await bot.loop();
|
||||||
|
expect(onAlertSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("UP"));
|
fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1.00%"));
|
await bot.loop();
|
||||||
|
|
||||||
|
expect(onAlertSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAlertSpy.mock.calls[0][0].previousPrice).toBe("50000.000000");
|
||||||
|
expect(onAlertSpy.mock.calls[0][0].newPrice).toBe("50005.000000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Polling mechanism", () => {
|
describe("Error Handling", () => {
|
||||||
it("should repeatedly call check()", async () => {
|
it("should handle network errors gracefully without crashing", async () => {
|
||||||
const checkSpy = vi.spyOn(bot, "check").mockResolvedValue();
|
fetchRateMock.mockRejectedValue(new Error("Network error"));
|
||||||
|
await expect(bot.loop()).resolves.not.toThrow();
|
||||||
bot.start();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(INTERVAL * 3);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(checkSpy).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Error handling", () => {
|
it("should handle alert callback errors", async () => {
|
||||||
it("should bubble up errors when check() is called directly", async () => {
|
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
|
||||||
nock("https://api.uphold.com")
|
await bot.loop();
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
|
||||||
.replyWithError("Connection Error");
|
|
||||||
|
|
||||||
await expect(bot.check()).rejects.toThrow("Connection Error");
|
fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
|
||||||
});
|
onAlertSpy.mockRejectedValue(new Error("DB Connection Lost"));
|
||||||
|
|
||||||
it("should reject invalid data formats", async () => {
|
await expect(bot.loop()).resolves.not.toThrow();
|
||||||
nock("https://api.uphold.com")
|
|
||||||
.get(`/v0/ticker/${PAIR}`)
|
|
||||||
.reply(200, { bid: "100" });
|
|
||||||
await expect(bot.check()).rejects.toThrow("Invalid data format");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("API", () => {
|
||||||
|
let api;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
nock.cleanAll();
|
||||||
|
|
||||||
|
vi.doMock("../src/api.js", async () => {
|
||||||
|
return await vi.importActual("../src/api.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
api = await import("../src/api.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchRate Caching", () => {
|
||||||
|
it("should cache successful responses", async () => {
|
||||||
|
const scope = nock("https://api.uphold.com")
|
||||||
|
.get("/v0/ticker/BTC-USD")
|
||||||
|
.reply(200, { ask: "60000" });
|
||||||
|
|
||||||
|
const price1 = await api.fetchRate("BTC-USD");
|
||||||
|
expect(price1.toFixed(0)).toBe("60000");
|
||||||
|
|
||||||
|
const price2 = await api.fetchRate("BTC-USD");
|
||||||
|
expect(price2.toFixed(0)).toBe("60000");
|
||||||
|
|
||||||
|
expect(scope.isDone()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should bypass cache if forceRefresh is true", async () => {
|
||||||
|
const scope = nock("https://api.uphold.com")
|
||||||
|
.get("/v0/ticker/BTC-USD")
|
||||||
|
.times(2)
|
||||||
|
.reply(200, { ask: "60000" });
|
||||||
|
|
||||||
|
await api.fetchRate("BTC-USD");
|
||||||
|
await api.fetchRate("BTC-USD", true);
|
||||||
|
|
||||||
|
expect(scope.isDone()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Request Deduplication", () => {
|
||||||
|
it("should deduplicate concurrent requests", async () => {
|
||||||
|
const scope = nock("https://api.uphold.com")
|
||||||
|
.get("/v0/ticker/BTC-USD")
|
||||||
|
.delay(100)
|
||||||
|
.reply(200, { ask: "60000" });
|
||||||
|
|
||||||
|
const p1 = api.fetchRate("BTC-USD", true);
|
||||||
|
const p2 = api.fetchRate("BTC-USD", true);
|
||||||
|
|
||||||
|
const [r1, r2] = await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
expect(r1).toEqual(r2);
|
||||||
|
expect(scope.isDone()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Retry Logic", () => {
|
||||||
|
it("should retry on failure", async () => {
|
||||||
|
const scope = nock("https://api.uphold.com")
|
||||||
|
.get("/v0/ticker/BTC-USD")
|
||||||
|
.replyWithError("Socket Hangup")
|
||||||
|
.get("/v0/ticker/BTC-USD")
|
||||||
|
.reply(200, { ask: "60000" });
|
||||||
|
|
||||||
|
const price = await api.fetchRate("BTC-USD", true);
|
||||||
|
expect(price.toFixed(0)).toBe("60000");
|
||||||
|
expect(scope.isDone()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw after max retries", async () => {
|
||||||
|
const scope = nock("https://api.uphold.com")
|
||||||
|
.get("/v0/ticker/BTC-USD")
|
||||||
|
.times(3)
|
||||||
|
.replyWithError("Persistent Fail");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.fetchRate("BTC-USD", true);
|
||||||
|
expect.fail("Should have thrown error");
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.message).toContain("Persistent Fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scope.isDone()).toBe(true);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue