From a34c1cf85f17f73e0b259da0bf7aab3ae32e2c2b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 14 Apr 2026 17:33:11 +0700 Subject: [PATCH] refactor: move totalvnd into meta.invested for extensibility Portfolio schema now uses meta object: { currency, assets, meta: { invested } }. Migrates old totalvnd field automatically on load. The meta object provides a clean place for future per-user metadata without polluting the top level. --- src/modules/trading/README.md | 8 +++---- src/modules/trading/handlers.js | 2 +- src/modules/trading/portfolio.js | 30 +++++++++++++------------ src/modules/trading/stats-handler.js | 4 ++-- tests/modules/trading/handlers.test.js | 8 +++---- tests/modules/trading/portfolio.test.js | 10 ++++----- 6 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/modules/trading/README.md b/src/modules/trading/README.md index 5e6d3e4..391b749 100644 --- a/src/modules/trading/README.md +++ b/src/modules/trading/README.md @@ -6,7 +6,7 @@ Paper-trading system where each Telegram user manages a virtual portfolio. Curre | Command | Action | |---------|--------| -| `/trade_topup ` | Add VND to account. Tracks cumulative invested via `totalvnd`. | +| `/trade_topup ` | Add VND to account. Tracks cumulative invested in `meta.invested`. | | `/trade_buy ` | Buy VN stock at market price, deducting VND. Integer quantities only. | | `/trade_sell ` | Sell stock holdings back to VND at market price. | | `/trade_convert` | Currency exchange (coming soon). | @@ -38,14 +38,14 @@ KV namespace prefix: `trading:` { "currency": { "VND": 5000000 }, "assets": { "TCB": 10, "FPT": 5, "VNM": 100 }, - "totalvnd": 10000000 + "meta": { "invested": 10000000 } } ``` - `currency` — fiat balances (VND only for now) - `assets` — flat map of stock quantities keyed by ticker -- `totalvnd` — cumulative VND value of all top-ups (cost basis for P&L) -- Migrates old 4-category format (`stock`/`crypto`/`others`) automatically on load +- `meta.invested` — cumulative VND value of all top-ups (cost basis for P&L) +- Migrates old formats automatically on load (`totalvnd` → `meta.invested`, `stock`/`crypto`/`others` → `assets`) ### Schema: `sym:` diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js index a9a0a8c..2c9429a 100644 --- a/src/modules/trading/handlers.js +++ b/src/modules/trading/handlers.js @@ -39,7 +39,7 @@ export async function handleTopup(ctx, db) { const p = await getPortfolio(db, uid(ctx)); addCurrency(p, "VND", amount); - p.totalvnd += amount; + p.meta.invested += amount; await savePortfolio(db, uid(ctx), p); await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`); } diff --git a/src/modules/trading/portfolio.js b/src/modules/trading/portfolio.js index ac0bf5a..3e5f8f8 100644 --- a/src/modules/trading/portfolio.js +++ b/src/modules/trading/portfolio.js @@ -2,20 +2,23 @@ * @file Portfolio CRUD — per-user KV read/write and balance operations. * All mutations are in-memory; caller must savePortfolio() to persist. * - * Schema: { currency: { VND, USD }, assets: { SYMBOL: qty }, totalvnd } + * Schema: { currency: { VND }, assets: { SYMBOL: qty }, meta: { invested } } * Assets are stored in a flat map — category is derived from symbol resolution. */ /** + * @typedef {Object} PortfolioMeta + * @property {number} invested — cumulative VND value of all top-ups (cost basis for P&L) + * * @typedef {Object} Portfolio * @property {{ [currency: string]: number }} currency * @property {{ [symbol: string]: number }} assets - * @property {number} totalvnd + * @property {PortfolioMeta} meta */ /** @returns {Portfolio} */ export function emptyPortfolio() { - return { currency: { VND: 0 }, assets: {}, totalvnd: 0 }; + return { currency: { VND: 0 }, assets: {}, meta: { invested: 0 } }; } /** @@ -29,20 +32,19 @@ export async function getPortfolio(db, userId) { const raw = await db.getJSON(`user:${userId}`); if (!raw) return emptyPortfolio(); - // migrate old format: merge stock/crypto/others into flat assets - if (raw.stock || raw.crypto || raw.others) { - const assets = { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets }; - return { - currency: { VND: 0, ...raw.currency }, - assets, - totalvnd: raw.totalvnd ?? 0, - }; - } + // migrate: merge old stock/crypto/others into flat assets + const assets = + raw.stock || raw.crypto || raw.others + ? { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets } + : (raw.assets ?? {}); + + // migrate: totalvnd → meta.invested + const invested = raw.meta?.invested ?? raw.totalvnd ?? 0; return { currency: { VND: 0, ...raw.currency }, - assets: raw.assets ?? {}, - totalvnd: raw.totalvnd ?? 0, + assets, + meta: { invested }, }; } diff --git a/src/modules/trading/stats-handler.js b/src/modules/trading/stats-handler.js index 03dc9ea..e684789 100644 --- a/src/modules/trading/stats-handler.js +++ b/src/modules/trading/stats-handler.js @@ -44,7 +44,7 @@ export async function handleStats(ctx, db) { } lines.push(`\nTotal value: ${formatVND(totalValue)}`); - lines.push(`Invested: ${formatVND(p.totalvnd)}`); - lines.push(`P&L: ${formatPnL(totalValue, p.totalvnd)}`); + lines.push(`Invested: ${formatVND(p.meta.invested)}`); + lines.push(`P&L: ${formatPnL(totalValue, p.meta.invested)}`); await ctx.reply(lines.join("\n")); } diff --git a/tests/modules/trading/handlers.test.js b/tests/modules/trading/handlers.test.js index 8422b94..06b791d 100644 --- a/tests/modules/trading/handlers.test.js +++ b/tests/modules/trading/handlers.test.js @@ -62,12 +62,12 @@ describe("trading/handlers", () => { expect(ctx.replies[0]).toContain("5.000.000 VND"); }); - it("tracks totalvnd", async () => { + it("tracks meta.invested", async () => { const ctx = makeCtx("1000000"); await handleTopup(ctx, db); const { getPortfolio } = await import("../../../src/modules/trading/portfolio.js"); const p = await getPortfolio(db, 42); - expect(p.totalvnd).toBe(1000000); + expect(p.meta.invested).toBe(1000000); expect(p.currency.VND).toBe(1000000); }); @@ -89,7 +89,7 @@ describe("trading/handlers", () => { const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js"); const p = emptyPortfolio(); p.currency.VND = 5000000; - p.totalvnd = 5000000; + p.meta.invested = 5000000; await savePortfolio(db, 42, p); const ctx = makeCtx("10 TCB"); @@ -169,7 +169,7 @@ describe("trading/handlers", () => { const p = emptyPortfolio(); p.currency.VND = 5000000; p.assets.TCB = 10; - p.totalvnd = 10000000; + p.meta.invested = 10000000; await savePortfolio(db, 42, p); const ctx = makeCtx(""); diff --git a/tests/modules/trading/portfolio.test.js b/tests/modules/trading/portfolio.test.js index 2af3c4b..dc911f8 100644 --- a/tests/modules/trading/portfolio.test.js +++ b/tests/modules/trading/portfolio.test.js @@ -23,7 +23,7 @@ describe("trading/portfolio", () => { const p = emptyPortfolio(); expect(p.currency).toEqual({ VND: 0 }); expect(p.assets).toEqual({}); - expect(p.totalvnd).toBe(0); + expect(p.meta.invested).toBe(0); }); }); @@ -31,7 +31,7 @@ describe("trading/portfolio", () => { 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); + expect(p.meta.invested).toBe(0); expect(p.assets).toEqual({}); }); @@ -39,12 +39,12 @@ describe("trading/portfolio", () => { const p = emptyPortfolio(); p.currency.VND = 5000000; p.assets.TCB = 10; - p.totalvnd = 5000000; + p.meta.invested = 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); + expect(loaded.meta.invested).toBe(5000000); }); it("migrates old 4-category format to flat assets", async () => { @@ -54,7 +54,7 @@ describe("trading/portfolio", () => { stock: { TCB: 10 }, crypto: { BTC: 0.5 }, others: { GOLD: 1 }, - totalvnd: 100, + totalvnd: 100, // old format — should be migrated to meta.invested }); const p = await getPortfolio(db, 123); expect(p.assets.TCB).toBe(10);