refactor: dynamic symbol resolution, flat portfolio, VN stocks only

Replace hardcoded 9-symbol registry with dynamic TCBS-based resolution.
Any VN stock ticker is now resolved on first use and cached in KV
permanently. Portfolio flattened from 4 category maps to single assets
map with automatic migration of old format. Crypto, gold, and currency
exchange disabled with "coming soon" message.
This commit is contained in:
2026-04-14 17:22:05 +07:00
parent 86268341d1
commit 0d4feb9ef8
10 changed files with 315 additions and 500 deletions

View File

@@ -10,7 +10,6 @@ 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 {
@@ -21,27 +20,13 @@ function makeCtx(match = "", userId = 42) {
};
}
/** Stub global.fetch to return canned price data */
/** Stub fetch — TCBS returns stock data, BIDV returns forex */
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 }] }),
json: () => Promise.resolve({ data: [{ close: 25 }] }),
});
}
if (url.includes("bidv")) {
@@ -49,10 +34,7 @@ function stubFetch() {
ok: true,
json: () =>
Promise.resolve({
data: [
{ currency: "USD", muaCk: "25,200", ban: "25,600" },
{ currency: "EUR", muaCk: "28,000", ban: "28,500" },
],
data: [{ currency: "USD", muaCk: "25,200", ban: "25,600" }],
}),
});
}
@@ -103,30 +85,34 @@ describe("trading/handlers", () => {
});
describe("handleBuy", () => {
it("buys crypto with sufficient VND", async () => {
// seed VND balance
it("buys stock with sufficient VND", async () => {
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = emptyPortfolio();
p.currency.VND = 20000000000;
p.totalvnd = 20000000000;
p.currency.VND = 5000000;
p.totalvnd = 5000000;
await savePortfolio(db, 42, p);
const ctx = makeCtx("0.01 BTC");
const ctx = makeCtx("10 TCB");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Bought");
expect(ctx.replies[0]).toContain("BTC");
expect(ctx.replies[0]).toContain("TCB");
});
it("rejects buy with insufficient VND", async () => {
const ctx = makeCtx("1 BTC");
const ctx = makeCtx("10 TCB");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient VND");
});
it("rejects unknown symbol", async () => {
const ctx = makeCtx("1 NOPE");
it("rejects unknown ticker", async () => {
// stub TCBS to return empty data for unknown ticker
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [] }) }),
);
const ctx = makeCtx("10 NOPE");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Unknown symbol");
expect(ctx.replies[0]).toContain("Unknown stock ticker");
expect(ctx.replies[0]).toContain("coming soon");
});
it("rejects fractional stock quantity", async () => {
@@ -143,70 +129,30 @@ describe("trading/handlers", () => {
});
describe("handleSell", () => {
it("sells crypto holding", async () => {
it("sells stock holding", async () => {
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = emptyPortfolio();
p.crypto.BTC = 0.5;
p.assets.TCB = 50;
await savePortfolio(db, 42, p);
const ctx = makeCtx("0.1 BTC");
const ctx = makeCtx("10 TCB");
await handleSell(ctx, db);
expect(ctx.replies[0]).toContain("Sold");
expect(ctx.replies[0]).toContain("BTC");
expect(ctx.replies[0]).toContain("TCB");
});
it("rejects sell with insufficient holdings", async () => {
const ctx = makeCtx("1 BTC");
const ctx = makeCtx("10 TCB");
await handleSell(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient BTC");
expect(ctx.replies[0]).toContain("Insufficient TCB");
});
});
describe("handleConvert", () => {
it("converts USD to VND at buy rate (bank buys USD at lower price)", async () => {
const { emptyPortfolio, getPortfolio } = 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");
// buy rate = 25,200 → 50 * 25200 = 1,260,000 VND
const loaded = await getPortfolio(db, 42);
expect(loaded.currency.VND).toBe(50 * 25200);
expect(ctx.replies[0]).toContain("buy:");
expect(ctx.replies[0]).toContain("sell:");
});
it("converts VND to USD at sell rate (bank sells USD at higher price)", async () => {
const { emptyPortfolio, getPortfolio } = await import(
"../../../src/modules/trading/portfolio.js"
);
const p = emptyPortfolio();
p.currency.VND = 30000000;
await savePortfolio(db, 42, p);
const ctx = makeCtx("1000000 VND USD");
await handleConvert(ctx, db);
expect(ctx.replies[0]).toContain("Converted");
// sell rate = 25,600 → 1M / 25600 ≈ 39.0625 USD
const loaded = await getPortfolio(db, 42);
expect(loaded.currency.USD).toBeCloseTo(1000000 / 25600, 2);
});
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 () => {
it("shows coming soon message", async () => {
const ctx = makeCtx("100 USD VND");
await handleConvert(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient USD");
await handleConvert(ctx);
expect(ctx.replies[0]).toContain("coming soon");
});
});
@@ -218,17 +164,17 @@ describe("trading/handlers", () => {
expect(ctx.replies[0]).toContain("Total value:");
});
it("shows portfolio with assets", async () => {
it("shows portfolio with stock 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;
p.assets.TCB = 10;
p.totalvnd = 10000000;
await savePortfolio(db, 42, p);
const ctx = makeCtx("");
await handleStats(ctx, db);
expect(ctx.replies[0]).toContain("BTC");
expect(ctx.replies[0]).toContain("TCB");
expect(ctx.replies[0]).toContain("P&L:");
});
});

View File

@@ -12,7 +12,6 @@ import {
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
describe("trading/portfolio", () => {
/** @type {import("../../../src/db/kv-store-interface.js").KVStore} */
let db;
beforeEach(() => {
@@ -22,10 +21,8 @@ describe("trading/portfolio", () => {
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.currency).toEqual({ VND: 0 });
expect(p.assets).toEqual({});
expect(p.totalvnd).toBe(0);
});
});
@@ -35,24 +32,37 @@ describe("trading/portfolio", () => {
const p = await getPortfolio(db, 123);
expect(p.currency.VND).toBe(0);
expect(p.totalvnd).toBe(0);
expect(p.assets).toEqual({});
});
it("round-trips saved data", async () => {
const p = emptyPortfolio();
p.currency.VND = 5000000;
p.assets.TCB = 10;
p.totalvnd = 5000000;
await savePortfolio(db, 123, p);
const loaded = await getPortfolio(db, 123);
expect(loaded.currency.VND).toBe(5000000);
expect(loaded.assets.TCB).toBe(10);
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: {} });
it("migrates old 4-category format to flat assets", async () => {
// simulate old format with stock/crypto/others
await db.putJSON("user:123", {
currency: { VND: 100 },
stock: { TCB: 10 },
crypto: { BTC: 0.5 },
others: { GOLD: 1 },
totalvnd: 100,
});
const p = await getPortfolio(db, 123);
expect(p.others).toEqual({});
expect(p.totalvnd).toBe(0);
expect(p.assets.TCB).toBe(10);
expect(p.assets.BTC).toBe(0.5);
expect(p.assets.GOLD).toBe(1);
// old category keys should not exist
expect(p.stock).toBeUndefined();
expect(p.crypto).toBeUndefined();
});
});
@@ -77,63 +87,51 @@ describe("trading/portfolio", () => {
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", () => {
it("adds asset to flat map", () => {
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);
expect(p.assets.TCB).toBe(10);
});
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);
addAsset(p, "TCB", 10);
addAsset(p, "TCB", 5);
expect(p.assets.TCB).toBe(15);
});
it("deducts asset when sufficient", () => {
const p = emptyPortfolio();
p.crypto.BTC = 1.0;
const result = deductAsset(p, "BTC", 0.3);
p.assets.TCB = 10;
const result = deductAsset(p, "TCB", 3);
expect(result.ok).toBe(true);
expect(p.crypto.BTC).toBeCloseTo(0.7);
expect(p.assets.TCB).toBe(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();
p.assets.TCB = 5;
deductAsset(p, "TCB", 5);
expect(p.assets.TCB).toBeUndefined();
});
it("rejects deduction when insufficient", () => {
const p = emptyPortfolio();
p.crypto.BTC = 0.1;
const result = deductAsset(p, "BTC", 0.5);
p.assets.TCB = 3;
const result = deductAsset(p, "TCB", 10);
expect(result.ok).toBe(false);
expect(result.held).toBe(0.1);
expect(result.held).toBe(3);
});
it("rejects deduction for unknown symbol", () => {
it("rejects deduction for unowned symbol", () => {
const p = emptyPortfolio();
const result = deductAsset(p, "NOPE", 1);
expect(result.ok).toBe(false);
expect(result.held).toBe(0);
});
});
});

View File

@@ -1,46 +1,59 @@
import { describe, expect, it } from "vitest";
import {
CURRENCIES,
SYMBOLS,
getSymbol,
listSymbols,
} from "../../../src/modules/trading/symbols.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import { comingSoonMessage, resolveSymbol } from "../../../src/modules/trading/symbols.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
/** Stub global.fetch to return TCBS-like response */
function stubFetch(hasData = true) {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(hasData ? { data: [{ close: 25 }] } : { data: [] }),
}),
);
}
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"]));
let db;
beforeEach(() => {
db = createStore("trading", { KV: makeFakeKv() });
vi.restoreAllMocks();
});
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("resolves a valid VN stock ticker via TCBS", async () => {
stubFetch();
const result = await resolveSymbol(db, "TCB");
expect(result).toEqual({ symbol: "TCB", category: "stock", label: "TCB" });
expect(global.fetch).toHaveBeenCalledOnce();
});
it("getSymbol returns undefined for unknown symbols", () => {
expect(getSymbol("NOPE")).toBeUndefined();
expect(getSymbol("")).toBeUndefined();
expect(getSymbol(null)).toBeUndefined();
it("caches resolved symbol in KV", async () => {
stubFetch();
await resolveSymbol(db, "TCB");
// second call should use cache, not fetch
await resolveSymbol(db, "TCB");
expect(global.fetch).toHaveBeenCalledOnce();
});
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("is case-insensitive", async () => {
stubFetch();
const result = await resolveSymbol(db, "tcb");
expect(result.symbol).toBe("TCB");
});
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");
it("returns null for invalid ticker", async () => {
stubFetch(false);
const result = await resolveSymbol(db, "NOPE");
expect(result).toBeNull();
});
it("returns null for empty input", async () => {
const result = await resolveSymbol(db, "");
expect(result).toBeNull();
});
it("comingSoonMessage returns string", () => {
expect(comingSoonMessage()).toContain("coming soon");
});
});