Initial commit

This commit is contained in:
Vítor Vieira 2025-11-27 14:41:21 +00:00
commit 81cc5e5627
11 changed files with 452 additions and 0 deletions

3
.env Normal file
View file

@ -0,0 +1,3 @@
PAIRS=BTC-USD,ETH-USD,XRP-USD
INTERVAL=5000
THRESHOLD=0.05

2
.gitignore vendored Normal file
View file

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

12
Containerfile Normal file
View file

@ -0,0 +1,12 @@
FROM docker.io/node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENTRYPOINT ["node", "index.js"]
CMD ["--pairs", "BTC-USD"]

0
README.md Normal file
View file

28
compose.yml Normal file
View file

@ -0,0 +1,28 @@
services:
bot:
build: .
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://uphold:uphold_password@db:5432/uphold_alerts
- PAIRS=${PAIRS:-BTC-USD,ETH-USD}
- INTERVAL=${INTERVAL:-5000}
- THRESHOLD=${THRESHOLD:-0.01}
command: ["--pairs", "${PAIRS:-BTC-USD,ETH-USD}", "--interval", "${INTERVAL:-5000}", "--threshold", "${THRESHOLD:-0.01}"]
db:
image: docker.io/postgres:15-alpine
environment:
- POSTGRES_USER=uphold
- POSTGRES_PASSWORD=uphold_password
- POSTGRES_DB=uphold_alerts
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U uphold -d uphold_alerts"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:

66
index.js Normal file
View file

@ -0,0 +1,66 @@
import { parseArgs } from "node:util";
import { Bot } from "./src/bot.js";
import { initDB } from "./src/db.js";
const options = {
pairs: {
type: "string",
short: "p",
default: "BTC-USD",
},
interval: {
type: "string",
short: "i",
default: "5000",
},
threshold: {
type: "string",
short: "t",
default: "0.01",
},
};
const { values } = parseArgs({
options,
strict: true,
allowPositionals: true,
args: process.argv.slice(2),
});
const pairs = values.pairs.split(",").map((p) => p.trim());
const interval = parseInt(values.interval, 10);
const threshold = parseFloat(values.threshold);
// Basic validation
if (isNaN(interval) || interval < 1000) {
console.error("Error: Interval must be a number >= 1000ms");
process.exit(1);
}
async function run() {
console.log("Starting Uphold interview bot...");
console.log(
`Configuration: Pairs=[${pairs.join(", ")}] Interval=${interval}ms Threshold=${threshold}%`,
);
if (process.env.DATABASE_URL) {
await initDB();
} else {
console.warn("[Warning] No DATABASE_URL found. Alerts will NOT be saved.");
}
const bots = pairs.map((pair) => new Bot(pair, interval, threshold));
bots.forEach((b) => b.start());
process.on("SIGINT", () => {
console.log("\nShutting down...");
bots.forEach((bot) => bot.stop());
process.exit(0);
});
}
run().catch((err) => {
console.error("Fatal Error:", err);
process.exit(1);
});

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "uphold",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"test": "vitest run",
"start": "node index.js"
},
"keywords": [],
"author": "Vítor Vieira",
"license": "MIT",
"description": "Technical project for Backend Engineer role at Uphold",
"dependencies": {
"bignumber.js": "^9.3.1",
"pg": "^8.16.3"
},
"devDependencies": {
"nock": "^14.0.10",
"vitest": "^4.0.14"
}
}

16
src/api.js Normal file
View file

@ -0,0 +1,16 @@
import { BigNumber } from "bignumber.js";
export async function fetchRate(pair) {
const url = `https://api.uphold.com/v0/ticker/${pair}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status ${response.status}`);
}
const data = await response.json();
if (!data.ask || isNaN(parseFloat(data.ask))) {
throw new Error("Invalid data format");
}
return new BigNumber(data.ask);
}

82
src/bot.js Normal file
View file

@ -0,0 +1,82 @@
import { fetchRate } from "./api.js";
import { insertIntoDB } from "./db.js";
export class Bot {
constructor(pair, interval, threshold) {
this.pair = pair;
this.interval = interval;
this.threshold = threshold;
this.lastPrice = null;
this.running = null;
this.timer = null;
}
start() {
if (!this.running) {
this.running = true;
console.log(`[STARTED] Monitoring ${this.pair}`);
this.check().catch((err) =>
console.error(
`[ERROR] Initialization failed for ${this.pair}: ${err.message}`,
),
);
this.timer = setInterval(async () => {
try {
await this.check();
} catch (err) {
console.error(`[ERROR] ${this.pair}: ${err.message}`);
}
}, this.interval);
}
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
}
}
async check() {
const currentPrice = await fetchRate(this.pair);
if (!this.lastPrice) {
this.lastPrice = currentPrice;
console.log(`[INIT] ${this.pair} set to ${currentPrice.toFixed(2)}`);
return;
}
const diff = currentPrice.minus(this.lastPrice);
const percentDiff = diff.dividedBy(this.lastPrice).multipliedBy(100);
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,
direction,
previousPrice: prevPriceStr,
newPrice: priceStr,
percentChange: pctNum,
interval: this.interval,
threshold: this.threshold,
});
this.lastPrice = currentPrice;
}
}
alert(direction, priceStr, percentChange) {
const sign = direction === "UP" ? "+" : "-";
console.log(
`[ALERT] ${this.pair} ${direction} ${sign}${percentChange.toFixed(2)}% | Price: ${priceStr}`,
);
}
}

70
src/db.js Normal file
View file

@ -0,0 +1,70 @@
import pg from "pg";
import { setTimeout } from "timers/promises";
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const CREATE_TABLE_QUERY = `
CREATE TABLE IF NOT EXISTS alerts (
id SERIAL PRIMARY KEY,
pair VARCHAR(20) NOT NULL,
direction VARCHAR(4) NOT NULL,
previous_price NUMERIC NOT NULL,
new_price NUMERIC NOT NULL,
percent_change NUMERIC NOT NULL,
config_interval INTEGER NOT NULL,
config_threshold NUMERIC NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`;
export async function initDB() {
const maxRetries = 10;
const retryDelay = 2000;
for (let i = 0; i < maxRetries; i++) {
try {
await pool.query(CREATE_TABLE_QUERY);
console.log("[DB] Database initialized and connected.");
return;
} catch (err) {
if (i === maxRetries - 1) {
console.error("[DB] Failed to connect after all retries:", err.message);
throw err;
}
console.log(
`[DB] Connection attempt ${i + 1} failed, retrying in ${retryDelay}ms...`,
);
await setTimeout(retryDelay);
}
}
}
export async function insertIntoDB(data) {
const query = `
INSERT INTO alerts (
pair, direction, previous_price, new_price, percent_change,
config_interval, config_threshold
) VALUES ($1, $2, $3, $4, $5, $6, $7)
`;
const values = [
data.pair,
data.direction,
data.previousPrice,
data.newPrice,
data.percentChange,
data.interval,
data.threshold,
];
try {
await pool.query(query, values);
console.log(`[DB] Event saved for ${data.pair}`);
} catch (err) {
console.error(`[DB] Failed to save alert: ${err.message}`);
}
}

151
tests/bot.test.js Normal file
View file

@ -0,0 +1,151 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
beforeAll,
} from "vitest";
import nock from "nock";
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", () => ({
insertIntoDB: vi.fn(),
initDB: vi.fn(),
}));
describe("Test Suite", () => {
const PAIR = "BTC-USD";
const INTERVAL = 100;
const THRESHOLD = 0.01;
let bot;
beforeAll(() => {
// Ensure BigNumber config matches what we expect
BigNumber.config({
DECIMAL_PLACES: 10,
ROUNDING_MODE: BigNumber.ROUND_HALF_UP,
});
});
beforeEach(() => {
bot = new Bot(PAIR, INTERVAL, THRESHOLD);
vi.useFakeTimers();
});
afterEach(() => {
bot.stop();
vi.runAllTimers();
vi.useRealTimers();
nock.cleanAll();
vi.restoreAllMocks();
});
describe("Initialization and first fetch", () => {
it("should set lastPrice on first successful API call", async () => {
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "60000.50", bid: "59990.00", currency: "USD" });
await bot.check();
expect(bot.lastPrice).toBeDefined();
expect(bot.lastPrice.toFixed(2)).toBe("60000.50");
});
});
describe("Price change detection", () => {
beforeEach(async () => {
// Establish baseline
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50000.00" });
await bot.check();
nock.cleanAll();
});
it("should NOT alert when change is below threshold", async () => {
const alertSpy = vi.spyOn(bot, "alert");
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50004.99" }); // +0.00998%
await bot.check();
expect(alertSpy).not.toHaveBeenCalled();
});
it("should alert when price moves exactly 0.01% (threshold)", async () => {
const alertSpy = vi.spyOn(bot, "alert");
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50005.00" });
await bot.check();
// Check specifically for arguments passed to alert
expect(alertSpy).toHaveBeenCalledWith("UP", "50005.00", 0.01);
});
it("should alert on downward move", async () => {
const alertSpy = vi.spyOn(bot, "alert");
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "49500.00" }); // -1%
await bot.check();
expect(alertSpy).toHaveBeenCalledWith("DOWN", "49500.00", -1);
});
});
describe("Alert method", () => {
it("should log correct message for price increase", () => {
bot.lastPrice = new BigNumber("50000");
const consoleSpy = vi.spyOn(console, "log");
bot.alert("UP", "50500.00", 1);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("UP"));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1.00%"));
});
});
describe("Polling mechanism", () => {
it("should repeatedly call check()", async () => {
const checkSpy = vi.spyOn(bot, "check").mockResolvedValue();
bot.start();
vi.advanceTimersByTime(INTERVAL * 3);
await Promise.resolve();
expect(checkSpy).toHaveBeenCalledTimes(4);
});
});
describe("Error handling", () => {
it("should bubble up errors when check() is called directly", async () => {
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.replyWithError("Connection Error");
await expect(bot.check()).rejects.toThrow("Connection Error");
});
it("should reject invalid data formats", async () => {
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { bid: "100" });
await expect(bot.check()).rejects.toThrow("Invalid data format");
});
});
});