commit 81cc5e5627fd52bef1ae81ded95919dba514b66e Author: Vítor Vieira Date: Thu Nov 27 14:41:21 2025 +0000 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..f4aa35e --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +PAIRS=BTC-USD,ETH-USD,XRP-USD +INTERVAL=5000 +THRESHOLD=0.05 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..12dc925 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..5d1d8a2 --- /dev/null +++ b/compose.yml @@ -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: diff --git a/index.js b/index.js new file mode 100644 index 0000000..e60b1a9 --- /dev/null +++ b/index.js @@ -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); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..9489680 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..8db0dbd --- /dev/null +++ b/src/api.js @@ -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); +} diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..00fa015 --- /dev/null +++ b/src/bot.js @@ -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}`, + ); + } +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..3b2bdbd --- /dev/null +++ b/src/db.js @@ -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}`); + } +} diff --git a/tests/bot.test.js b/tests/bot.test.js new file mode 100644 index 0000000..f7f1db0 --- /dev/null +++ b/tests/bot.test.js @@ -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"); + }); + }); +});