From 81cc5e5627fd52bef1ae81ded95919dba514b66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Vieira?= Date: Thu, 27 Nov 2025 14:41:21 +0000 Subject: [PATCH] Initial commit --- .env | 3 + .gitignore | 2 + Containerfile | 12 ++++ README.md | 0 compose.yml | 28 +++++++++ index.js | 66 ++++++++++++++++++++ package.json | 22 +++++++ src/api.js | 16 +++++ src/bot.js | 82 +++++++++++++++++++++++++ src/db.js | 70 +++++++++++++++++++++ tests/bot.test.js | 151 ++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 452 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 index.js create mode 100644 package.json create mode 100644 src/api.js create mode 100644 src/bot.js create mode 100644 src/db.js create mode 100644 tests/bot.test.js 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"); + }); + }); +});