Initial commit
This commit is contained in:
commit
81cc5e5627
11 changed files with 452 additions and 0 deletions
3
.env
Normal file
3
.env
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
PAIRS=BTC-USD,ETH-USD,XRP-USD
|
||||
INTERVAL=5000
|
||||
THRESHOLD=0.05
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
package-lock.json
|
||||
12
Containerfile
Normal file
12
Containerfile
Normal 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
0
README.md
Normal file
28
compose.yml
Normal file
28
compose.yml
Normal 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
66
index.js
Normal 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
22
package.json
Normal 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
16
src/api.js
Normal 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
82
src/bot.js
Normal 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
70
src/db.js
Normal 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
151
tests/bot.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue