mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
feat: add fake trading module with crypto, stocks, forex and gold
Paper trading system with 5 commands (trade_topup, trade_buy, trade_sell, trade_convert, trade_stats). Supports VN stocks via TCBS, crypto via CoinGecko, forex via ER-API, and gold via PAX Gold proxy. Per-user portfolio stored in KV with 60s price caching. 54 new tests.
This commit is contained in:
79
tests/modules/trading/format.test.js
Normal file
79
tests/modules/trading/format.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCrypto,
|
||||
formatCurrency,
|
||||
formatPnL,
|
||||
formatStock,
|
||||
formatUSD,
|
||||
formatVND,
|
||||
} from "../../../src/modules/trading/format.js";
|
||||
|
||||
describe("trading/format", () => {
|
||||
describe("formatVND", () => {
|
||||
it("formats with dot thousands separator", () => {
|
||||
expect(formatVND(15000000)).toBe("15.000.000 VND");
|
||||
});
|
||||
it("handles zero", () => {
|
||||
expect(formatVND(0)).toBe("0 VND");
|
||||
});
|
||||
it("handles negative", () => {
|
||||
expect(formatVND(-1500000)).toBe("-1.500.000 VND");
|
||||
});
|
||||
it("rounds to integer", () => {
|
||||
expect(formatVND(1234.56)).toBe("1.235 VND");
|
||||
});
|
||||
it("handles small numbers", () => {
|
||||
expect(formatVND(500)).toBe("500 VND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUSD", () => {
|
||||
it("formats with comma separator and 2 decimals", () => {
|
||||
expect(formatUSD(1234.5)).toBe("$1,234.50");
|
||||
});
|
||||
it("handles zero", () => {
|
||||
expect(formatUSD(0)).toBe("$0.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCrypto", () => {
|
||||
it("strips trailing zeros", () => {
|
||||
expect(formatCrypto(0.001)).toBe("0.001");
|
||||
expect(formatCrypto(1.0)).toBe("1");
|
||||
});
|
||||
it("preserves up to 8 decimals", () => {
|
||||
expect(formatCrypto(0.12345678)).toBe("0.12345678");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStock", () => {
|
||||
it("floors to integer", () => {
|
||||
expect(formatStock(1.7)).toBe("1");
|
||||
expect(formatStock(150)).toBe("150");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCurrency", () => {
|
||||
it("dispatches to VND formatter", () => {
|
||||
expect(formatCurrency(5000, "VND")).toBe("5.000 VND");
|
||||
});
|
||||
it("dispatches to USD formatter", () => {
|
||||
expect(formatCurrency(100.5, "USD")).toBe("$100.50");
|
||||
});
|
||||
it("falls back for unknown currency", () => {
|
||||
expect(formatCurrency(42, "EUR")).toBe("42 EUR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPnL", () => {
|
||||
it("shows positive P&L", () => {
|
||||
expect(formatPnL(12000000, 10000000)).toBe("+2.000.000 VND (+20.00%)");
|
||||
});
|
||||
it("shows negative P&L", () => {
|
||||
expect(formatPnL(8000000, 10000000)).toBe("-2.000.000 VND (-20.00%)");
|
||||
});
|
||||
it("handles zero investment", () => {
|
||||
expect(formatPnL(0, 0)).toBe("+0 VND (+0.00%)");
|
||||
});
|
||||
});
|
||||
});
|
||||
216
tests/modules/trading/handlers.test.js
Normal file
216
tests/modules/trading/handlers.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
handleBuy,
|
||||
handleConvert,
|
||||
handleSell,
|
||||
handleTopup,
|
||||
} from "../../../src/modules/trading/handlers.js";
|
||||
import { savePortfolio } from "../../../src/modules/trading/portfolio.js";
|
||||
import { handleStats } from "../../../src/modules/trading/stats-handler.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
/** Build a fake grammY context with .match, .from, and .reply spy */
|
||||
function makeCtx(match = "", userId = 42) {
|
||||
const replies = [];
|
||||
return {
|
||||
match,
|
||||
from: { id: userId },
|
||||
reply: vi.fn((text) => replies.push(text)),
|
||||
replies,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stub global.fetch to return canned price data */
|
||||
function stubFetch() {
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes("coingecko")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
bitcoin: { vnd: 1500000000 },
|
||||
ethereum: { vnd: 50000000 },
|
||||
solana: { vnd: 3000000 },
|
||||
"pax-gold": { vnd: 72000000 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes("tcbs")) {
|
||||
const ticker = url.match(/ticker=(\w+)/)?.[1];
|
||||
const prices = { TCB: 25, VPB: 18, FPT: 120, VNM: 70, HPG: 28 };
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }),
|
||||
});
|
||||
}
|
||||
if (url.includes("er-api")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ rates: { VND: 25400 } }),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected fetch: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
describe("trading/handlers", () => {
|
||||
let db;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createStore("trading", { KV: makeFakeKv() });
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("handleTopup", () => {
|
||||
it("tops up VND", async () => {
|
||||
const ctx = makeCtx("5000000 VND");
|
||||
await handleTopup(ctx, db);
|
||||
expect(ctx.reply).toHaveBeenCalledOnce();
|
||||
expect(ctx.replies[0]).toContain("5.000.000 VND");
|
||||
});
|
||||
|
||||
it("tops up USD and tracks VND equivalent in totalvnd", async () => {
|
||||
const ctx = makeCtx("100 USD");
|
||||
await handleTopup(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("$100.00");
|
||||
});
|
||||
|
||||
it("defaults to VND when currency omitted", async () => {
|
||||
const ctx = makeCtx("1000000");
|
||||
await handleTopup(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("VND");
|
||||
});
|
||||
|
||||
it("rejects missing args", async () => {
|
||||
const ctx = makeCtx("");
|
||||
await handleTopup(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Usage:");
|
||||
});
|
||||
|
||||
it("rejects negative amount", async () => {
|
||||
const ctx = makeCtx("-100 VND");
|
||||
await handleTopup(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("positive");
|
||||
});
|
||||
|
||||
it("rejects unsupported currency", async () => {
|
||||
const ctx = makeCtx("100 EUR");
|
||||
await handleTopup(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleBuy", () => {
|
||||
it("buys crypto with sufficient VND", async () => {
|
||||
// seed VND balance
|
||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 20000000000;
|
||||
p.totalvnd = 20000000000;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
const ctx = makeCtx("0.01 BTC");
|
||||
await handleBuy(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Bought");
|
||||
expect(ctx.replies[0]).toContain("BTC");
|
||||
});
|
||||
|
||||
it("rejects buy with insufficient VND", async () => {
|
||||
const ctx = makeCtx("1 BTC");
|
||||
await handleBuy(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Insufficient VND");
|
||||
});
|
||||
|
||||
it("rejects unknown symbol", async () => {
|
||||
const ctx = makeCtx("1 NOPE");
|
||||
await handleBuy(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Unknown symbol");
|
||||
});
|
||||
|
||||
it("rejects fractional stock quantity", async () => {
|
||||
const ctx = makeCtx("1.5 TCB");
|
||||
await handleBuy(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("whole numbers");
|
||||
});
|
||||
|
||||
it("rejects missing args", async () => {
|
||||
const ctx = makeCtx("");
|
||||
await handleBuy(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSell", () => {
|
||||
it("sells crypto holding", async () => {
|
||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||
const p = emptyPortfolio();
|
||||
p.crypto.BTC = 0.5;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
const ctx = makeCtx("0.1 BTC");
|
||||
await handleSell(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Sold");
|
||||
expect(ctx.replies[0]).toContain("BTC");
|
||||
});
|
||||
|
||||
it("rejects sell with insufficient holdings", async () => {
|
||||
const ctx = makeCtx("1 BTC");
|
||||
await handleSell(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Insufficient BTC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleConvert", () => {
|
||||
it("converts USD to VND", async () => {
|
||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||
const p = emptyPortfolio();
|
||||
p.currency.USD = 100;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
const ctx = makeCtx("50 USD VND");
|
||||
await handleConvert(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Converted");
|
||||
expect(ctx.replies[0]).toContain("VND");
|
||||
});
|
||||
|
||||
it("rejects same currency conversion", async () => {
|
||||
const ctx = makeCtx("100 VND VND");
|
||||
await handleConvert(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("same currency");
|
||||
});
|
||||
|
||||
it("rejects insufficient balance", async () => {
|
||||
const ctx = makeCtx("100 USD VND");
|
||||
await handleConvert(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Insufficient USD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleStats", () => {
|
||||
it("shows empty portfolio for new user", async () => {
|
||||
const ctx = makeCtx("");
|
||||
await handleStats(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("Portfolio Summary");
|
||||
expect(ctx.replies[0]).toContain("Total value:");
|
||||
});
|
||||
|
||||
it("shows portfolio with assets", async () => {
|
||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 5000000;
|
||||
p.crypto.BTC = 0.01;
|
||||
p.totalvnd = 20000000;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
const ctx = makeCtx("");
|
||||
await handleStats(ctx, db);
|
||||
expect(ctx.replies[0]).toContain("BTC");
|
||||
expect(ctx.replies[0]).toContain("P&L:");
|
||||
});
|
||||
});
|
||||
});
|
||||
139
tests/modules/trading/portfolio.test.js
Normal file
139
tests/modules/trading/portfolio.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
addAsset,
|
||||
addCurrency,
|
||||
deductAsset,
|
||||
deductCurrency,
|
||||
emptyPortfolio,
|
||||
getPortfolio,
|
||||
savePortfolio,
|
||||
} from "../../../src/modules/trading/portfolio.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
describe("trading/portfolio", () => {
|
||||
/** @type {import("../../../src/db/kv-store-interface.js").KVStore} */
|
||||
let db;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createStore("trading", { KV: makeFakeKv() });
|
||||
});
|
||||
|
||||
describe("emptyPortfolio", () => {
|
||||
it("returns correct shape", () => {
|
||||
const p = emptyPortfolio();
|
||||
expect(p.currency).toEqual({ VND: 0, USD: 0 });
|
||||
expect(p.stock).toEqual({});
|
||||
expect(p.crypto).toEqual({});
|
||||
expect(p.others).toEqual({});
|
||||
expect(p.totalvnd).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPortfolio / savePortfolio", () => {
|
||||
it("returns empty portfolio for new user", async () => {
|
||||
const p = await getPortfolio(db, 123);
|
||||
expect(p.currency.VND).toBe(0);
|
||||
expect(p.totalvnd).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips saved data", async () => {
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 5000000;
|
||||
p.totalvnd = 5000000;
|
||||
await savePortfolio(db, 123, p);
|
||||
const loaded = await getPortfolio(db, 123);
|
||||
expect(loaded.currency.VND).toBe(5000000);
|
||||
expect(loaded.totalvnd).toBe(5000000);
|
||||
});
|
||||
|
||||
it("fills missing category keys on load (migration-safe)", async () => {
|
||||
// simulate old data missing 'others' key
|
||||
await db.putJSON("user:123", { currency: { VND: 100 }, stock: {}, crypto: {} });
|
||||
const p = await getPortfolio(db, 123);
|
||||
expect(p.others).toEqual({});
|
||||
expect(p.totalvnd).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCurrency / deductCurrency", () => {
|
||||
it("adds currency", () => {
|
||||
const p = emptyPortfolio();
|
||||
addCurrency(p, "VND", 1000000);
|
||||
expect(p.currency.VND).toBe(1000000);
|
||||
});
|
||||
|
||||
it("deducts currency when sufficient", () => {
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 5000000;
|
||||
const result = deductCurrency(p, "VND", 3000000);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(p.currency.VND).toBe(2000000);
|
||||
});
|
||||
|
||||
it("rejects deduction when insufficient", () => {
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 1000;
|
||||
const result = deductCurrency(p, "VND", 5000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.balance).toBe(1000);
|
||||
expect(p.currency.VND).toBe(1000); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAsset / deductAsset", () => {
|
||||
it("adds crypto asset", () => {
|
||||
const p = emptyPortfolio();
|
||||
addAsset(p, "BTC", 0.5);
|
||||
expect(p.crypto.BTC).toBe(0.5);
|
||||
});
|
||||
|
||||
it("adds stock asset", () => {
|
||||
const p = emptyPortfolio();
|
||||
addAsset(p, "TCB", 10);
|
||||
expect(p.stock.TCB).toBe(10);
|
||||
});
|
||||
|
||||
it("adds others asset", () => {
|
||||
const p = emptyPortfolio();
|
||||
addAsset(p, "GOLD", 1);
|
||||
expect(p.others.GOLD).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates on repeated add", () => {
|
||||
const p = emptyPortfolio();
|
||||
addAsset(p, "BTC", 0.1);
|
||||
addAsset(p, "BTC", 0.2);
|
||||
expect(p.crypto.BTC).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it("deducts asset when sufficient", () => {
|
||||
const p = emptyPortfolio();
|
||||
p.crypto.BTC = 1.0;
|
||||
const result = deductAsset(p, "BTC", 0.3);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(p.crypto.BTC).toBeCloseTo(0.7);
|
||||
});
|
||||
|
||||
it("removes key when deducted to zero", () => {
|
||||
const p = emptyPortfolio();
|
||||
p.crypto.BTC = 0.5;
|
||||
deductAsset(p, "BTC", 0.5);
|
||||
expect(p.crypto.BTC).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects deduction when insufficient", () => {
|
||||
const p = emptyPortfolio();
|
||||
p.crypto.BTC = 0.1;
|
||||
const result = deductAsset(p, "BTC", 0.5);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.held).toBe(0.1);
|
||||
});
|
||||
|
||||
it("rejects deduction for unknown symbol", () => {
|
||||
const p = emptyPortfolio();
|
||||
const result = deductAsset(p, "NOPE", 1);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
tests/modules/trading/symbols.test.js
Normal file
46
tests/modules/trading/symbols.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CURRENCIES,
|
||||
SYMBOLS,
|
||||
getSymbol,
|
||||
listSymbols,
|
||||
} from "../../../src/modules/trading/symbols.js";
|
||||
|
||||
describe("trading/symbols", () => {
|
||||
it("SYMBOLS has 9 entries across 3 categories", () => {
|
||||
expect(Object.keys(SYMBOLS)).toHaveLength(9);
|
||||
const cats = new Set(Object.values(SYMBOLS).map((s) => s.category));
|
||||
expect(cats).toEqual(new Set(["crypto", "stock", "others"]));
|
||||
});
|
||||
|
||||
it("getSymbol is case-insensitive", () => {
|
||||
const btc = getSymbol("btc");
|
||||
expect(btc).toBeDefined();
|
||||
expect(btc.symbol).toBe("BTC");
|
||||
expect(btc.category).toBe("crypto");
|
||||
|
||||
expect(getSymbol("Tcb").symbol).toBe("TCB");
|
||||
});
|
||||
|
||||
it("getSymbol returns undefined for unknown symbols", () => {
|
||||
expect(getSymbol("NOPE")).toBeUndefined();
|
||||
expect(getSymbol("")).toBeUndefined();
|
||||
expect(getSymbol(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("CURRENCIES contains VND and USD", () => {
|
||||
expect(CURRENCIES.has("VND")).toBe(true);
|
||||
expect(CURRENCIES.has("USD")).toBe(true);
|
||||
expect(CURRENCIES.has("EUR")).toBe(false);
|
||||
});
|
||||
|
||||
it("listSymbols returns grouped output", () => {
|
||||
const out = listSymbols();
|
||||
expect(out).toContain("Crypto:");
|
||||
expect(out).toContain("BTC — Bitcoin");
|
||||
expect(out).toContain("Stocks:");
|
||||
expect(out).toContain("TCB — Techcombank");
|
||||
expect(out).toContain("Others:");
|
||||
expect(out).toContain("GOLD — Gold");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user