mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 15:20:58 +00:00
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.
This commit is contained in:
@@ -6,7 +6,7 @@ Paper-trading system where each Telegram user manages a virtual portfolio. Curre
|
|||||||
|
|
||||||
| Command | Action |
|
| Command | Action |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| `/trade_topup <amount>` | Add VND to account. Tracks cumulative invested via `totalvnd`. |
|
| `/trade_topup <amount>` | Add VND to account. Tracks cumulative invested in `meta.invested`. |
|
||||||
| `/trade_buy <qty> <TICKER>` | Buy VN stock at market price, deducting VND. Integer quantities only. |
|
| `/trade_buy <qty> <TICKER>` | Buy VN stock at market price, deducting VND. Integer quantities only. |
|
||||||
| `/trade_sell <qty> <TICKER>` | Sell stock holdings back to VND at market price. |
|
| `/trade_sell <qty> <TICKER>` | Sell stock holdings back to VND at market price. |
|
||||||
| `/trade_convert` | Currency exchange (coming soon). |
|
| `/trade_convert` | Currency exchange (coming soon). |
|
||||||
@@ -38,14 +38,14 @@ KV namespace prefix: `trading:`
|
|||||||
{
|
{
|
||||||
"currency": { "VND": 5000000 },
|
"currency": { "VND": 5000000 },
|
||||||
"assets": { "TCB": 10, "FPT": 5, "VNM": 100 },
|
"assets": { "TCB": 10, "FPT": 5, "VNM": 100 },
|
||||||
"totalvnd": 10000000
|
"meta": { "invested": 10000000 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `currency` — fiat balances (VND only for now)
|
- `currency` — fiat balances (VND only for now)
|
||||||
- `assets` — flat map of stock quantities keyed by ticker
|
- `assets` — flat map of stock quantities keyed by ticker
|
||||||
- `totalvnd` — cumulative VND value of all top-ups (cost basis for P&L)
|
- `meta.invested` — cumulative VND value of all top-ups (cost basis for P&L)
|
||||||
- Migrates old 4-category format (`stock`/`crypto`/`others`) automatically on load
|
- Migrates old formats automatically on load (`totalvnd` → `meta.invested`, `stock`/`crypto`/`others` → `assets`)
|
||||||
|
|
||||||
### Schema: `sym:<TICKER>`
|
### Schema: `sym:<TICKER>`
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function handleTopup(ctx, db) {
|
|||||||
|
|
||||||
const p = await getPortfolio(db, uid(ctx));
|
const p = await getPortfolio(db, uid(ctx));
|
||||||
addCurrency(p, "VND", amount);
|
addCurrency(p, "VND", amount);
|
||||||
p.totalvnd += amount;
|
p.meta.invested += amount;
|
||||||
await savePortfolio(db, uid(ctx), p);
|
await savePortfolio(db, uid(ctx), p);
|
||||||
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,23 @@
|
|||||||
* @file Portfolio CRUD — per-user KV read/write and balance operations.
|
* @file Portfolio CRUD — per-user KV read/write and balance operations.
|
||||||
* All mutations are in-memory; caller must savePortfolio() to persist.
|
* 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.
|
* 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
|
* @typedef {Object} Portfolio
|
||||||
* @property {{ [currency: string]: number }} currency
|
* @property {{ [currency: string]: number }} currency
|
||||||
* @property {{ [symbol: string]: number }} assets
|
* @property {{ [symbol: string]: number }} assets
|
||||||
* @property {number} totalvnd
|
* @property {PortfolioMeta} meta
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @returns {Portfolio} */
|
/** @returns {Portfolio} */
|
||||||
export function emptyPortfolio() {
|
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}`);
|
const raw = await db.getJSON(`user:${userId}`);
|
||||||
if (!raw) return emptyPortfolio();
|
if (!raw) return emptyPortfolio();
|
||||||
|
|
||||||
// migrate old format: merge stock/crypto/others into flat assets
|
// migrate: merge old stock/crypto/others into flat assets
|
||||||
if (raw.stock || raw.crypto || raw.others) {
|
const assets =
|
||||||
const assets = { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets };
|
raw.stock || raw.crypto || raw.others
|
||||||
return {
|
? { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets }
|
||||||
currency: { VND: 0, ...raw.currency },
|
: (raw.assets ?? {});
|
||||||
assets,
|
|
||||||
totalvnd: raw.totalvnd ?? 0,
|
// migrate: totalvnd → meta.invested
|
||||||
};
|
const invested = raw.meta?.invested ?? raw.totalvnd ?? 0;
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currency: { VND: 0, ...raw.currency },
|
currency: { VND: 0, ...raw.currency },
|
||||||
assets: raw.assets ?? {},
|
assets,
|
||||||
totalvnd: raw.totalvnd ?? 0,
|
meta: { invested },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function handleStats(ctx, db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`\nTotal value: ${formatVND(totalValue)}`);
|
lines.push(`\nTotal value: ${formatVND(totalValue)}`);
|
||||||
lines.push(`Invested: ${formatVND(p.totalvnd)}`);
|
lines.push(`Invested: ${formatVND(p.meta.invested)}`);
|
||||||
lines.push(`P&L: ${formatPnL(totalValue, p.totalvnd)}`);
|
lines.push(`P&L: ${formatPnL(totalValue, p.meta.invested)}`);
|
||||||
await ctx.reply(lines.join("\n"));
|
await ctx.reply(lines.join("\n"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ describe("trading/handlers", () => {
|
|||||||
expect(ctx.replies[0]).toContain("5.000.000 VND");
|
expect(ctx.replies[0]).toContain("5.000.000 VND");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks totalvnd", async () => {
|
it("tracks meta.invested", async () => {
|
||||||
const ctx = makeCtx("1000000");
|
const ctx = makeCtx("1000000");
|
||||||
await handleTopup(ctx, db);
|
await handleTopup(ctx, db);
|
||||||
const { getPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
const { getPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||||
const p = await getPortfolio(db, 42);
|
const p = await getPortfolio(db, 42);
|
||||||
expect(p.totalvnd).toBe(1000000);
|
expect(p.meta.invested).toBe(1000000);
|
||||||
expect(p.currency.VND).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 { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.VND = 5000000;
|
p.currency.VND = 5000000;
|
||||||
p.totalvnd = 5000000;
|
p.meta.invested = 5000000;
|
||||||
await savePortfolio(db, 42, p);
|
await savePortfolio(db, 42, p);
|
||||||
|
|
||||||
const ctx = makeCtx("10 TCB");
|
const ctx = makeCtx("10 TCB");
|
||||||
@@ -169,7 +169,7 @@ describe("trading/handlers", () => {
|
|||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.VND = 5000000;
|
p.currency.VND = 5000000;
|
||||||
p.assets.TCB = 10;
|
p.assets.TCB = 10;
|
||||||
p.totalvnd = 10000000;
|
p.meta.invested = 10000000;
|
||||||
await savePortfolio(db, 42, p);
|
await savePortfolio(db, 42, p);
|
||||||
|
|
||||||
const ctx = makeCtx("");
|
const ctx = makeCtx("");
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("trading/portfolio", () => {
|
|||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
expect(p.currency).toEqual({ VND: 0 });
|
expect(p.currency).toEqual({ VND: 0 });
|
||||||
expect(p.assets).toEqual({});
|
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 () => {
|
it("returns empty portfolio for new user", async () => {
|
||||||
const p = await getPortfolio(db, 123);
|
const p = await getPortfolio(db, 123);
|
||||||
expect(p.currency.VND).toBe(0);
|
expect(p.currency.VND).toBe(0);
|
||||||
expect(p.totalvnd).toBe(0);
|
expect(p.meta.invested).toBe(0);
|
||||||
expect(p.assets).toEqual({});
|
expect(p.assets).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,12 +39,12 @@ describe("trading/portfolio", () => {
|
|||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.VND = 5000000;
|
p.currency.VND = 5000000;
|
||||||
p.assets.TCB = 10;
|
p.assets.TCB = 10;
|
||||||
p.totalvnd = 5000000;
|
p.meta.invested = 5000000;
|
||||||
await savePortfolio(db, 123, p);
|
await savePortfolio(db, 123, p);
|
||||||
const loaded = await getPortfolio(db, 123);
|
const loaded = await getPortfolio(db, 123);
|
||||||
expect(loaded.currency.VND).toBe(5000000);
|
expect(loaded.currency.VND).toBe(5000000);
|
||||||
expect(loaded.assets.TCB).toBe(10);
|
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 () => {
|
it("migrates old 4-category format to flat assets", async () => {
|
||||||
@@ -54,7 +54,7 @@ describe("trading/portfolio", () => {
|
|||||||
stock: { TCB: 10 },
|
stock: { TCB: 10 },
|
||||||
crypto: { BTC: 0.5 },
|
crypto: { BTC: 0.5 },
|
||||||
others: { GOLD: 1 },
|
others: { GOLD: 1 },
|
||||||
totalvnd: 100,
|
totalvnd: 100, // old format — should be migrated to meta.invested
|
||||||
});
|
});
|
||||||
const p = await getPortfolio(db, 123);
|
const p = await getPortfolio(db, 123);
|
||||||
expect(p.assets.TCB).toBe(10);
|
expect(p.assets.TCB).toBe(10);
|
||||||
|
|||||||
Reference in New Issue
Block a user