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