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

@@ -1,32 +1,26 @@
# Trading Module # Trading Module
Paper-trading system where each Telegram user manages a virtual portfolio. Paper-trading system where each Telegram user manages a virtual portfolio. Currently supports **VN stocks only** — crypto, gold, and currency exchange coming later.
## Commands ## Commands
| 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 via `totalvnd`. |
| `/trade_buy <amount> <symbol>` | Buy at market price, deducting VND. Stocks must be integer quantities. | | `/trade_buy <qty> <TICKER>` | Buy VN stock at market price, deducting VND. Integer quantities only. |
| `/trade_sell <amount> <symbol>` | Sell holdings back to VND at market price. | | `/trade_sell <qty> <TICKER>` | Sell stock holdings back to VND at market price. |
| `/trade_convert <amount> <from> <to>` | Convert between currencies at real BIDV bid/ask rates. | | `/trade_convert` | Currency exchange (coming soon). |
| `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. | | `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. |
## Supported Symbols ## Symbol Resolution
| Symbol | Category | Source | Label | Symbols are **resolved dynamically** — no hardcoded registry. When a user buys a ticker:
|--------|----------|--------|-------|
| BTC | crypto | CoinGecko | Bitcoin |
| ETH | crypto | CoinGecko | Ethereum |
| SOL | crypto | CoinGecko | Solana |
| TCB | stock | TCBS | Techcombank |
| VPB | stock | TCBS | VPBank |
| FPT | stock | TCBS | FPT Corp |
| VNM | stock | TCBS | Vinamilk |
| HPG | stock | TCBS | Hoa Phat |
| GOLD | others | CoinGecko (PAX Gold) | Gold (troy oz) |
Currencies: VND, USD. 1. Check KV cache (`sym:<TICKER>`) → if cached, use it
2. Query TCBS API to verify the ticker exists and has price data
3. Cache the resolution permanently in KV
Any valid VN stock ticker on TCBS "just works" without code changes.
## Database ## Database
@@ -34,74 +28,55 @@ KV namespace prefix: `trading:`
| Key | Type | Description | | Key | Type | Description |
|-----|------|-------------| |-----|------|-------------|
| `user:<telegramId>` | JSON | Per-user portfolio (balances + holdings) | | `user:<telegramId>` | JSON | Per-user portfolio |
| `prices:latest` | JSON | Cached merged prices from all APIs | | `sym:<TICKER>` | JSON | Cached symbol resolution |
| `forex:latest` | JSON | Cached BIDV forex rates |
### Schema: `user:<telegramId>` ### Schema: `user:<telegramId>`
```json ```json
{ {
"currency": { "VND": 5000000, "USD": 100 }, "currency": { "VND": 5000000 },
"stock": { "TCB": 10, "FPT": 5 }, "assets": { "TCB": 10, "FPT": 5, "VNM": 100 },
"crypto": { "BTC": 0.005, "ETH": 1.2 },
"others": { "GOLD": 0.1 },
"totalvnd": 10000000 "totalvnd": 10000000
} }
``` ```
- `currency` — fiat balances (VND, USD) - `currency` — fiat balances (VND only for now)
- `stock` / `crypto` / `others` — asset quantities keyed by symbol - `assets` — flat map of stock quantities keyed by ticker
- `totalvnd` — cumulative VND value of all top-ups (cost basis for P&L) - `totalvnd` — cumulative VND value of all top-ups (cost basis for P&L)
- VND is the sole settlement currency — buy/sell deducts/adds VND - Migrates old 4-category format (`stock`/`crypto`/`others`) automatically on load
- Empty categories are `{}`, not absent — migration-safe loading fills missing keys
### Schema: `prices:latest` ### Schema: `sym:<TICKER>`
```json ```json
{ { "symbol": "TCB", "category": "stock", "label": "TCB" }
"ts": 1713100000000,
"crypto": { "BTC": 1500000000, "ETH": 50000000, "SOL": 3000000 },
"stock": { "TCB": 25000, "VPB": 18000, "FPT": 120000, "VNM": 70000, "HPG": 28000 },
"forex": { "USD": { "mid": 25400, "buy": 25200, "sell": 25600 } },
"others": { "GOLD": 72000000 }
}
``` ```
- `ts` — Unix epoch milliseconds of last fetch Cached permanently after first successful TCBS lookup.
- All prices in VND per unit
- Cache TTL: 60 seconds (stale fallback up to 5 minutes)
## Price Sources ## Price Source
Three free APIs fetched in parallel, cached in KV for 60 seconds: | API | Purpose | Auth |
|-----|---------|------|
| TCBS `/stock-insight/v1/stock/bars-long-term` | VN stock close price (× 1000) | None |
| API | Purpose | Auth | Rate Limit | Prices are fetched on demand per symbol (not batch-cached), since any ticker can be queried dynamically.
|-----|---------|------|-----------|
| CoinGecko `/api/v3/simple/price` | Crypto + gold prices in VND | None | 30 calls/min (free) |
| TCBS `/stock-insight/v1/stock/bars-long-term` | Vietnam stock close prices (× 1000) | None | Unofficial |
| BIDV `/ServicesBIDV/ExchangeDetailServlet` | USD/VND buy/sell rates | None | Unofficial |
On partial API failure, available data is returned. On total failure, stale cache up to 5 minutes old is used before surfacing an error.
## File Layout ## File Layout
``` ```
src/modules/trading/ src/modules/trading/
├── index.js — module entry, wires handlers to commands ├── index.js — module entry, wires handlers to commands
├── symbols.js — hardcoded symbol registry (9 assets, 2 currencies) ├── symbols.js — dynamic symbol resolution via TCBS + KV cache
├── format.js — VND/USD/crypto/stock/P&L formatters ├── format.js — VND/stock number formatters
├── portfolio.js — per-user KV read/write, balance checks ├── portfolio.js — per-user KV read/write, flat assets map
├── prices.js — API fetching + 60s cache ├── prices.js — TCBS stock price fetch + BIDV forex (for future use)
├── handlers.js — topup/buy/sell/convert handlers ├── handlers.js — topup/buy/sell/convert handlers
└── stats-handler.js — stats/P&L breakdown handler └── stats-handler.js — stats/P&L breakdown handler
``` ```
## Adding a Symbol ## Future
Add one line to `symbols.js`: - Crypto (CoinGecko), gold (PAX Gold), currency exchange (BIDV bid/ask rates)
- Dynamic symbol resolution will extend to CoinGecko search for crypto
```js
NEWSYM: { category: "crypto", apiId: "coingecko-id", label: "New Coin" },
```
For stocks, `apiId` is the TCBS ticker. For crypto/gold, `apiId` is the CoinGecko ID.

View File

@@ -1,9 +1,10 @@
/** /**
* @file Command handler implementations for the trading module. * @file Command handler implementations for the trading module.
* Each handler receives (ctx, db) — the grammY context and KV store. * Each handler receives (ctx, db) — the grammY context and KV store.
* Currently only VN stocks are supported. Crypto/gold/convert coming later.
*/ */
import { formatCrypto, formatCurrency, formatStock, formatVND } from "./format.js"; import { formatStock, formatVND } from "./format.js";
import { import {
addAsset, addAsset,
addCurrency, addCurrency,
@@ -12,8 +13,8 @@ import {
getPortfolio, getPortfolio,
savePortfolio, savePortfolio,
} from "./portfolio.js"; } from "./portfolio.js";
import { getForexBidAsk, getPrice } from "./prices.js"; import { getStockPrice } from "./prices.js";
import { CURRENCIES, getSymbol, listSymbols } from "./symbols.js"; import { comingSoonMessage, resolveSymbol } from "./symbols.js";
function uid(ctx) { function uid(ctx) {
return ctx.from?.id; return ctx.from?.id;
@@ -43,22 +44,23 @@ export async function handleTopup(ctx, db) {
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`); await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
} }
/** /trade_buy <amount> <symbol> */ /** /trade_buy <amount> <symbol> — buy VN stock at market price */
export async function handleBuy(ctx, db) { export async function handleBuy(ctx, db) {
const args = parseArgs(ctx); const args = parseArgs(ctx);
if (args.length < 2) if (args.length < 2)
return usageReply(ctx, "/trade_buy <amount> <SYMBOL>\nExample: /trade_buy 0.01 BTC"); return usageReply(ctx, "/trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB");
const amount = Number(args[0]); const amount = Number(args[0]);
if (!Number.isFinite(amount) || amount <= 0) if (!Number.isFinite(amount) || amount <= 0)
return ctx.reply("Amount must be a positive number."); return ctx.reply("Amount must be a positive number.");
const info = getSymbol(args[1]); if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`);
if (info.category === "stock" && !Number.isInteger(amount)) const info = await resolveSymbol(db, args[1]);
return ctx.reply("Stock quantities must be whole numbers."); if (!info)
return ctx.reply(`Unknown stock ticker "${args[1].toUpperCase()}".\n${comingSoonMessage()}`);
let price; let price;
try { try {
price = await getPrice(db, info.symbol); price = await getStockPrice(info.symbol);
} catch { } catch {
return ctx.reply("Could not fetch price. Try again later."); return ctx.reply("Could not fetch price. Try again later.");
} }
@@ -74,94 +76,43 @@ export async function handleBuy(ctx, db) {
} }
addAsset(p, info.symbol, amount); addAsset(p, info.symbol, amount);
await savePortfolio(db, uid(ctx), p); await savePortfolio(db, uid(ctx), p);
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount); await ctx.reply(
await ctx.reply(`Bought ${qty} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`); `Bought ${formatStock(amount)} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`,
);
} }
/** /trade_sell <amount> <symbol> */ /** /trade_sell <amount> <symbol> — sell VN stock back to VND */
export async function handleSell(ctx, db) { export async function handleSell(ctx, db) {
const args = parseArgs(ctx); const args = parseArgs(ctx);
if (args.length < 2) if (args.length < 2)
return usageReply(ctx, "/trade_sell <amount> <SYMBOL>\nExample: /trade_sell 0.01 BTC"); return usageReply(ctx, "/trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB");
const amount = Number(args[0]); const amount = Number(args[0]);
if (!Number.isFinite(amount) || amount <= 0) if (!Number.isFinite(amount) || amount <= 0)
return ctx.reply("Amount must be a positive number."); return ctx.reply("Amount must be a positive number.");
const info = getSymbol(args[1]); if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`);
if (info.category === "stock" && !Number.isInteger(amount))
return ctx.reply("Stock quantities must be whole numbers.");
const symbol = args[1].toUpperCase();
const p = await getPortfolio(db, uid(ctx)); const p = await getPortfolio(db, uid(ctx));
const result = deductAsset(p, info.symbol, amount); const result = deductAsset(p, symbol, amount);
if (!result.ok) { if (!result.ok) return ctx.reply(`Insufficient ${symbol}. You have: ${formatStock(result.held)}`);
const qty = info.category === "stock" ? formatStock(result.held) : formatCrypto(result.held);
return ctx.reply(`Insufficient ${info.symbol}. You have: ${qty}`);
}
let price; let price;
try { try {
price = await getPrice(db, info.symbol); price = await getStockPrice(symbol);
} catch { } catch {
return ctx.reply("Could not fetch price. Try again later."); return ctx.reply("Could not fetch price. Try again later.");
} }
if (price == null) return ctx.reply(`No price available for ${info.symbol}.`); if (price == null) return ctx.reply(`No price available for ${symbol}.`);
const revenue = amount * price; const revenue = amount * price;
addCurrency(p, "VND", revenue); addCurrency(p, "VND", revenue);
await savePortfolio(db, uid(ctx), p); await savePortfolio(db, uid(ctx), p);
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
await ctx.reply( await ctx.reply(
`Sold ${qty} ${info.symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`, `Sold ${formatStock(amount)} ${symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
); );
} }
/** /trade_convert <amount> <from> <to> — with bid/ask spread */ /** /trade_convert — disabled, coming soon */
export async function handleConvert(ctx, db) { export async function handleConvert(ctx) {
const args = parseArgs(ctx); await ctx.reply(`Currency exchange is not available yet.\n${comingSoonMessage()}`);
if (args.length < 3)
return usageReply(
ctx,
"/trade_convert <amount> <FROM> <TO>\nExample: /trade_convert 100 USD VND",
);
const amount = Number(args[0]);
if (!Number.isFinite(amount) || amount <= 0)
return ctx.reply("Amount must be a positive number.");
const from = args[1].toUpperCase();
const to = args[2].toUpperCase();
if (!CURRENCIES.has(from) || !CURRENCIES.has(to))
return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`);
if (from === to) return ctx.reply("Cannot convert to the same currency.");
let rates;
try {
rates = await getForexBidAsk(db, "USD");
} catch {
return ctx.reply("Could not fetch forex rate. Try again later.");
}
if (!rates) return ctx.reply("Forex rate unavailable. Try again later.");
const p = await getPortfolio(db, uid(ctx));
const result = deductCurrency(p, from, amount);
if (!result.ok)
return ctx.reply(`Insufficient ${from}. Balance: ${formatCurrency(result.balance, from)}`);
// buy = bank buys USD (you sell USD → VND), sell = bank sells USD (you buy USD → pay VND)
let converted;
let rateUsed;
if (from === "VND" && to === "USD") {
// you're buying USD from bank → bank sells at higher price
converted = amount / rates.sell;
rateUsed = rates.sell;
} else {
// you're selling USD to bank → bank buys at lower price
converted = amount * rates.buy;
rateUsed = rates.buy;
}
addCurrency(p, to, converted);
await savePortfolio(db, uid(ctx), p);
const spread = (((rates.sell - rates.buy) / rates.buy) * 100).toFixed(2);
await ctx.reply(
`Converted ${formatCurrency(amount, from)}${formatCurrency(converted, to)}\nRate: ${formatVND(rateUsed)}/USD (buy: ${formatVND(rates.buy)}, sell: ${formatVND(rates.sell)}, spread: ${spread}%)`,
);
} }

View File

@@ -25,20 +25,20 @@ const tradingModule = {
{ {
name: "trade_buy", name: "trade_buy",
visibility: "public", visibility: "public",
description: "Buy crypto/stock/gold at market price", description: "Buy VN stock at market price",
handler: (ctx) => handleBuy(ctx, db), handler: (ctx) => handleBuy(ctx, db),
}, },
{ {
name: "trade_sell", name: "trade_sell",
visibility: "public", visibility: "public",
description: "Sell holdings back to VND", description: "Sell VN stock back to VND",
handler: (ctx) => handleSell(ctx, db), handler: (ctx) => handleSell(ctx, db),
}, },
{ {
name: "trade_convert", name: "trade_convert",
visibility: "public", visibility: "public",
description: "Convert between currencies (bid/ask spread)", description: "Currency exchange (coming soon)",
handler: (ctx) => handleConvert(ctx, db), handler: (ctx) => handleConvert(ctx),
}, },
{ {
name: "trade_stats", name: "trade_stats",

View File

@@ -1,27 +1,26 @@
/** /**
* @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 }
* Assets are stored in a flat map — category is derived from symbol resolution.
*/ */
import { getSymbol } from "./symbols.js";
/** /**
* @typedef {Object} Portfolio * @typedef {Object} Portfolio
* @property {{ [currency: string]: number }} currency * @property {{ [currency: string]: number }} currency
* @property {{ [symbol: string]: number }} stock * @property {{ [symbol: string]: number }} assets
* @property {{ [symbol: string]: number }} crypto
* @property {{ [symbol: string]: number }} others
* @property {number} totalvnd * @property {number} totalvnd
*/ */
/** @returns {Portfolio} */ /** @returns {Portfolio} */
export function emptyPortfolio() { export function emptyPortfolio() {
return { currency: { VND: 0, USD: 0 }, stock: {}, crypto: {}, others: {}, totalvnd: 0 }; return { currency: { VND: 0 }, assets: {}, totalvnd: 0 };
} }
/** /**
* Load user portfolio from KV, or return empty if first-time user. * Load user portfolio from KV, or return empty if first-time user.
* Ensures all category keys exist (migration-safe). * Migrates old 4-category format to flat assets map.
* @param {import("../../db/kv-store-interface.js").KVStore} db * @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} userId * @param {number|string} userId
* @returns {Promise<Portfolio>} * @returns {Promise<Portfolio>}
@@ -29,14 +28,22 @@ export function emptyPortfolio() {
export async function getPortfolio(db, userId) { 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();
// ensure all expected keys exist
const p = emptyPortfolio(); // migrate old format: merge stock/crypto/others into flat assets
p.currency = { ...p.currency, ...raw.currency }; if (raw.stock || raw.crypto || raw.others) {
p.stock = { ...raw.stock }; const assets = { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets };
p.crypto = { ...raw.crypto }; return {
p.others = { ...raw.others }; currency: { VND: 0, ...raw.currency },
p.totalvnd = raw.totalvnd ?? 0; assets,
return p; totalvnd: raw.totalvnd ?? 0,
};
}
return {
currency: { VND: 0, ...raw.currency },
assets: raw.assets ?? {},
totalvnd: raw.totalvnd ?? 0,
};
} }
/** /**
@@ -74,16 +81,13 @@ export function deductCurrency(p, currency, amount) {
} }
/** /**
* Add asset (stock/crypto/others) to portfolio. * Add asset to flat assets map.
* @param {Portfolio} p * @param {Portfolio} p
* @param {string} symbol * @param {string} symbol
* @param {number} qty * @param {number} qty
*/ */
export function addAsset(p, symbol, qty) { export function addAsset(p, symbol, qty) {
const info = getSymbol(symbol); p.assets[symbol] = (p.assets[symbol] || 0) + qty;
if (!info) return;
const cat = info.category;
p[cat][symbol] = (p[cat][symbol] || 0) + qty;
} }
/** /**
@@ -95,13 +99,10 @@ export function addAsset(p, symbol, qty) {
* @returns {{ ok: boolean, held: number }} * @returns {{ ok: boolean, held: number }}
*/ */
export function deductAsset(p, symbol, qty) { export function deductAsset(p, symbol, qty) {
const info = getSymbol(symbol); const held = p.assets[symbol] || 0;
if (!info) return { ok: false, held: 0 };
const cat = info.category;
const held = p[cat][symbol] || 0;
if (held < qty) return { ok: false, held }; if (held < qty) return { ok: false, held };
const remaining = held - qty; const remaining = held - qty;
if (remaining === 0) delete p[cat][symbol]; if (remaining === 0) delete p.assets[symbol];
else p[cat][symbol] = remaining; else p.assets[symbol] = remaining;
return { ok: true, held: remaining }; return { ok: true, held: remaining };
} }

View File

@@ -1,57 +1,27 @@
/** /**
* @file Price fetching — CoinGecko (crypto+gold), TCBS (VN stocks), ER-API (forex). * @file Price fetching — TCBS (VN stocks) + BIDV (forex).
* Caches merged result in KV for 60s to avoid API spam. * Single-stock price fetch on demand. Forex rates cached for 60s.
*/ */
import { SYMBOLS } from "./symbols.js"; const FOREX_CACHE_KEY = "forex:latest";
const CACHE_KEY = "prices:latest";
const CACHE_TTL_MS = 60_000; const CACHE_TTL_MS = 60_000;
const STALE_LIMIT_MS = 300_000; // 5 min — max age for fallback const STALE_LIMIT_MS = 300_000;
/** Crypto + gold via CoinGecko free API */ /**
async function fetchCrypto() { * Fetch current VND price for a VN stock ticker via TCBS.
const ids = Object.values(SYMBOLS) * Returns close price * 1000 (TCBS convention).
.filter((s) => s.category === "crypto" || s.category === "others") * @param {string} ticker — uppercase, e.g. "TCB"
.map((s) => s.apiId) * @returns {Promise<number|null>}
.join(","); */
const res = await fetch( export async function fetchStockPrice(ticker) {
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=vnd`,
);
if (!res.ok) throw new Error(`CoinGecko ${res.status}`);
const data = await res.json();
const crypto = {};
const others = {};
for (const [sym, entry] of Object.entries(SYMBOLS)) {
if (entry.category !== "crypto" && entry.category !== "others") continue;
const price = data[entry.apiId]?.vnd;
if (price == null) continue;
if (entry.category === "crypto") crypto[sym] = price;
else others[sym] = price;
}
return { crypto, others };
}
/** Vietnam stock prices via TCBS public API (price in VND * 1000) */
async function fetchStocks() {
const tickers = Object.entries(SYMBOLS).filter(([, e]) => e.category === "stock");
const to = Math.floor(Date.now() / 1000); const to = Math.floor(Date.now() / 1000);
const results = await Promise.allSettled( const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${ticker}&type=stock&resolution=D&countBack=1&to=${to}`;
tickers.map(async ([sym, entry]) => {
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${entry.apiId}&type=stock&resolution=D&countBack=1&to=${to}`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(`TCBS ${entry.apiId} ${res.status}`); if (!res.ok) return null;
const json = await res.json(); const json = await res.json();
const close = json?.data?.[0]?.close; const close = json?.data?.[0]?.close;
if (close == null) throw new Error(`TCBS ${entry.apiId} no close`); if (close == null) return null;
return { sym, price: close * 1000 }; return close * 1000;
}),
);
const stock = {};
for (const r of results) {
if (r.status === "fulfilled") stock[r.value.sym] = r.value.price;
}
return stock;
} }
/** Forex rates via BIDV public API — returns real buy/sell rates */ /** Forex rates via BIDV public API — returns real buy/sell rates */
@@ -61,7 +31,6 @@ async function fetchForex() {
const json = await res.json(); const json = await res.json();
const usd = json?.data?.find((r) => r.currency === "USD"); const usd = json?.data?.find((r) => r.currency === "USD");
if (!usd) throw new Error("BIDV missing USD rate"); if (!usd) throw new Error("BIDV missing USD rate");
// muaCk = bank buy (transfer), ban = bank sell — parse "26,141" → 26141
const parse = (s) => Number.parseFloat(String(s).replace(/,/g, "")); const parse = (s) => Number.parseFloat(String(s).replace(/,/g, ""));
const buy = parse(usd.muaCk); const buy = parse(usd.muaCk);
const sell = parse(usd.ban); const sell = parse(usd.ban);
@@ -72,82 +41,58 @@ async function fetchForex() {
} }
/** /**
* Fetch all prices in parallel, merge, cache in KV. * Get VND price for a stock symbol.
* @param {import("../../db/kv-store-interface.js").KVStore} db * @param {string} symbol — uppercase ticker
*/
export async function fetchPrices(db) {
const [cryptoRes, stockRes, forexRes] = await Promise.allSettled([
fetchCrypto(),
fetchStocks(),
fetchForex(),
]);
const merged = {
ts: Date.now(),
crypto: cryptoRes.status === "fulfilled" ? cryptoRes.value.crypto : {},
stock: stockRes.status === "fulfilled" ? stockRes.value : {},
forex: forexRes.status === "fulfilled" ? forexRes.value : {},
others: cryptoRes.status === "fulfilled" ? cryptoRes.value.others : {},
};
try {
await db.putJSON(CACHE_KEY, merged);
} catch {
/* best effort cache write */
}
return merged;
}
/**
* Cache-first price retrieval. Returns cached if < 60s old, else fetches fresh.
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function getPrices(db) {
const cached = await db.getJSON(CACHE_KEY);
if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached;
try {
return await fetchPrices(db);
} catch {
// fallback to stale cache if < 5 min
if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached;
throw new Error("Could not fetch prices. Try again later.");
}
}
/**
* Get VND price for a single symbol.
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {string} symbol — uppercase, e.g. "BTC"
* @returns {Promise<number|null>} * @returns {Promise<number|null>}
*/ */
export async function getPrice(db, symbol) { export async function getStockPrice(symbol) {
const info = SYMBOLS[symbol]; return fetchStockPrice(symbol);
if (!info) return null; }
const prices = await getPrices(db);
return prices[info.category]?.[symbol] ?? null; /**
* Cache-first forex rate retrieval.
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
async function getForexRates(db) {
const cached = await db.getJSON(FOREX_CACHE_KEY);
if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached;
try {
const forex = await fetchForex();
const data = { ts: Date.now(), ...forex };
try {
await db.putJSON(FOREX_CACHE_KEY, data);
} catch {
/* best effort */
}
return data;
} catch {
if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached;
throw new Error("Could not fetch forex rates. Try again later.");
}
} }
/** /**
* Mid-rate VND equivalent of 1 unit of currency (for stats display). * Mid-rate VND equivalent of 1 unit of currency (for stats display).
* @param {import("../../db/kv-store-interface.js").KVStore} db * @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {string} currency — "VND" or "USD" * @param {string} currency
* @returns {Promise<number|null>} * @returns {Promise<number|null>}
*/ */
export async function getForexRate(db, currency) { export async function getForexRate(db, currency) {
if (currency === "VND") return 1; if (currency === "VND") return 1;
const prices = await getPrices(db); const rates = await getForexRates(db);
return prices.forex?.[currency]?.mid ?? null; return rates[currency]?.mid ?? null;
} }
/** /**
* Buy/sell forex rates for a currency. * Buy/sell forex rates for a currency.
* buy = bank buys USD from you (you sell USD → get VND), sell = bank sells USD to you (you buy USD → pay VND).
* @param {import("../../db/kv-store-interface.js").KVStore} db * @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {string} currency * @param {string} currency
* @returns {Promise<{ buy: number, sell: number }|null>} * @returns {Promise<{ buy: number, sell: number }|null>}
*/ */
export async function getForexBidAsk(db, currency) { export async function getForexBidAsk(db, currency) {
if (currency === "VND") return null; if (currency === "VND") return null;
const prices = await getPrices(db); const rates = await getForexRates(db);
const rate = prices.forex?.[currency]; const rate = rates[currency];
if (!rate?.buy || !rate?.sell) return null; if (!rate?.buy || !rate?.sell) return null;
return { buy: rate.buy, sell: rate.sell }; return { buy: rate.buy, sell: rate.sell };
} }

View File

@@ -1,59 +1,46 @@
/** /**
* @file /trade_stats handler — portfolio summary with P&L breakdown. * @file /trade_stats handler — portfolio summary with P&L breakdown.
* Fetches live stock prices for each held asset.
*/ */
import { formatCrypto, formatCurrency, formatPnL, formatStock, formatVND } from "./format.js"; import { formatPnL, formatStock, formatVND } from "./format.js";
import { getPortfolio } from "./portfolio.js"; import { getPortfolio } from "./portfolio.js";
import { getPrices } from "./prices.js"; import { getStockPrice } from "./prices.js";
/** /trade_stats — show full portfolio valued in VND with P&L */ /** /trade_stats — show full portfolio valued in VND with P&L */
export async function handleStats(ctx, db) { export async function handleStats(ctx, db) {
const p = await getPortfolio(db, ctx.from?.id); const p = await getPortfolio(db, ctx.from?.id);
let prices;
try {
prices = await getPrices(db);
} catch {
return ctx.reply("Could not fetch prices. Try again later.");
}
const lines = ["📊 Portfolio Summary\n"]; const lines = ["📊 Portfolio Summary\n"];
let totalValue = 0; let totalValue = 0;
// currencies // VND balance
const currLines = []; const vnd = p.currency.VND || 0;
for (const [cur, bal] of Object.entries(p.currency)) { if (vnd > 0) {
if (bal === 0) continue; totalValue += vnd;
const rate = cur === "VND" ? 1 : (prices.forex?.[cur]?.mid ?? 0); lines.push(`VND: ${formatVND(vnd)}`);
const vndVal = bal * rate;
totalValue += vndVal;
currLines.push(
cur === "VND"
? ` VND: ${formatVND(bal)}`
: ` ${cur}: ${formatCurrency(bal, cur)} (~${formatVND(vndVal)})`,
);
} }
if (currLines.length) lines.push("Currency:", ...currLines);
// asset categories // stock assets
for (const [catName, catLabel] of [ const assetEntries = Object.entries(p.assets);
["stock", "Stocks"], if (assetEntries.length > 0) {
["crypto", "Crypto"], lines.push("\nStocks:");
["others", "Others"], for (const [sym, qty] of assetEntries) {
]) {
const catLines = [];
for (const [sym, qty] of Object.entries(p[catName])) {
if (qty === 0) continue; if (qty === 0) continue;
const price = prices[catName]?.[sym]; let price;
try {
price = await getStockPrice(sym);
} catch {
price = null;
}
if (price == null) { if (price == null) {
catLines.push(` ${sym}: ${qty} (no price)`); lines.push(` ${sym} x${formatStock(qty)} (no price)`);
continue; continue;
} }
const val = qty * price; const val = qty * price;
totalValue += val; totalValue += val;
const fmtQty = catName === "stock" ? formatStock(qty) : formatCrypto(qty); lines.push(` ${sym} x${formatStock(qty)} @ ${formatVND(price)} = ${formatVND(val)}`);
catLines.push(` ${sym} x${fmtQty} @ ${formatVND(price)} = ${formatVND(val)}`);
} }
if (catLines.length) lines.push(`\n${catLabel}:`, ...catLines);
} }
lines.push(`\nTotal value: ${formatVND(totalValue)}`); lines.push(`\nTotal value: ${formatVND(totalValue)}`);

View File

@@ -1,53 +1,52 @@
/** /**
* @file Symbol registry — hardcoded list of tradable assets + fiat currencies. * @file Symbol resolution — dynamically resolves stock tickers via TCBS API.
* Adding a new asset = one line here, no logic changes elsewhere. * Resolved symbols are cached in KV permanently to avoid repeated lookups.
* Currently only supports VN stocks. Crypto, gold, forex coming later.
*/ */
/** @typedef {{ category: "crypto"|"stock"|"others", apiId: string, label: string }} SymbolEntry */ const COMING_SOON = "Crypto, gold & currency exchange coming soon!";
/** @type {Readonly<Record<string, SymbolEntry>>} */
export const SYMBOLS = Object.freeze({
// crypto — CoinGecko IDs
BTC: { category: "crypto", apiId: "bitcoin", label: "Bitcoin" },
ETH: { category: "crypto", apiId: "ethereum", label: "Ethereum" },
SOL: { category: "crypto", apiId: "solana", label: "Solana" },
// Vietnam stocks — TCBS tickers
TCB: { category: "stock", apiId: "TCB", label: "Techcombank" },
VPB: { category: "stock", apiId: "VPB", label: "VPBank" },
FPT: { category: "stock", apiId: "FPT", label: "FPT Corp" },
VNM: { category: "stock", apiId: "VNM", label: "Vinamilk" },
HPG: { category: "stock", apiId: "HPG", label: "Hoa Phat" },
// others
GOLD: { category: "others", apiId: "pax-gold", label: "Gold (troy oz)" },
});
/** Supported fiat currencies */
export const CURRENCIES = Object.freeze(new Set(["VND", "USD"]));
/** /**
* Case-insensitive symbol lookup. * @typedef {Object} ResolvedSymbol
* @param {string} name * @property {string} symbol — uppercase ticker
* @returns {SymbolEntry & { symbol: string } | undefined} * @property {string} category — "stock" (only supported category for now)
* @property {string} label — company name
*/ */
export function getSymbol(name) {
if (!name) return undefined; /**
const key = name.toUpperCase(); * Resolve a ticker to a symbol entry. Checks KV cache first, then queries TCBS.
const entry = SYMBOLS[key]; * @param {import("../../db/kv-store-interface.js").KVStore} db
return entry ? { ...entry, symbol: key } : undefined; * @param {string} ticker — user input, case-insensitive
* @returns {Promise<ResolvedSymbol|null>} null if not found on TCBS
*/
export async function resolveSymbol(db, ticker) {
if (!ticker) return null;
const symbol = ticker.toUpperCase();
const cacheKey = `sym:${symbol}`;
// check KV cache
const cached = await db.getJSON(cacheKey);
if (cached) return cached;
// query TCBS to verify this is a real VN stock
const to = Math.floor(Date.now() / 1000);
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${symbol}&type=stock&resolution=D&countBack=1&to=${to}`;
const res = await fetch(url);
if (!res.ok) return null;
const json = await res.json();
const close = json?.data?.[0]?.close;
if (close == null) return null;
const entry = { symbol, category: "stock", label: symbol };
// cache permanently — stock tickers don't change
await db.putJSON(cacheKey, entry);
return entry;
} }
/** /**
* Formatted list of all supported symbols grouped by category. * Error message for unsupported asset types.
* @returns {string} * @returns {string}
*/ */
export function listSymbols() { export function comingSoonMessage() {
const groups = { crypto: [], stock: [], others: [] }; return COMING_SOON;
for (const [sym, entry] of Object.entries(SYMBOLS)) {
groups[entry.category].push(`${sym}${entry.label}`);
}
const lines = [];
if (groups.crypto.length) lines.push("Crypto:", ...groups.crypto.map((s) => ` ${s}`));
if (groups.stock.length) lines.push("Stocks:", ...groups.stock.map((s) => ` ${s}`));
if (groups.others.length) lines.push("Others:", ...groups.others.map((s) => ` ${s}`));
return lines.join("\n");
} }

View File

@@ -10,7 +10,6 @@ import { savePortfolio } from "../../../src/modules/trading/portfolio.js";
import { handleStats } from "../../../src/modules/trading/stats-handler.js"; import { handleStats } from "../../../src/modules/trading/stats-handler.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.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) { function makeCtx(match = "", userId = 42) {
const replies = []; const replies = [];
return { 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() { function stubFetch() {
global.fetch = vi.fn((url) => { 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")) { 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({ return Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }), json: () => Promise.resolve({ data: [{ close: 25 }] }),
}); });
} }
if (url.includes("bidv")) { if (url.includes("bidv")) {
@@ -49,10 +34,7 @@ function stubFetch() {
ok: true, ok: true,
json: () => json: () =>
Promise.resolve({ Promise.resolve({
data: [ data: [{ currency: "USD", muaCk: "25,200", ban: "25,600" }],
{ currency: "USD", muaCk: "25,200", ban: "25,600" },
{ currency: "EUR", muaCk: "28,000", ban: "28,500" },
],
}), }),
}); });
} }
@@ -103,30 +85,34 @@ describe("trading/handlers", () => {
}); });
describe("handleBuy", () => { describe("handleBuy", () => {
it("buys crypto with sufficient VND", async () => { it("buys stock with sufficient VND", async () => {
// seed VND balance
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 = 20000000000; p.currency.VND = 5000000;
p.totalvnd = 20000000000; p.totalvnd = 5000000;
await savePortfolio(db, 42, p); await savePortfolio(db, 42, p);
const ctx = makeCtx("0.01 BTC"); const ctx = makeCtx("10 TCB");
await handleBuy(ctx, db); await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Bought"); 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 () => { it("rejects buy with insufficient VND", async () => {
const ctx = makeCtx("1 BTC"); const ctx = makeCtx("10 TCB");
await handleBuy(ctx, db); await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient VND"); expect(ctx.replies[0]).toContain("Insufficient VND");
}); });
it("rejects unknown symbol", async () => { it("rejects unknown ticker", async () => {
const ctx = makeCtx("1 NOPE"); // 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); 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 () => { it("rejects fractional stock quantity", async () => {
@@ -143,70 +129,30 @@ describe("trading/handlers", () => {
}); });
describe("handleSell", () => { describe("handleSell", () => {
it("sells crypto holding", async () => { it("sells stock holding", async () => {
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.crypto.BTC = 0.5; p.assets.TCB = 50;
await savePortfolio(db, 42, p); await savePortfolio(db, 42, p);
const ctx = makeCtx("0.1 BTC"); const ctx = makeCtx("10 TCB");
await handleSell(ctx, db); await handleSell(ctx, db);
expect(ctx.replies[0]).toContain("Sold"); 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 () => { it("rejects sell with insufficient holdings", async () => {
const ctx = makeCtx("1 BTC"); const ctx = makeCtx("10 TCB");
await handleSell(ctx, db); await handleSell(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient BTC"); expect(ctx.replies[0]).toContain("Insufficient TCB");
}); });
}); });
describe("handleConvert", () => { describe("handleConvert", () => {
it("converts USD to VND at buy rate (bank buys USD at lower price)", async () => { it("shows coming soon message", 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 () => {
const ctx = makeCtx("100 USD VND"); const ctx = makeCtx("100 USD VND");
await handleConvert(ctx, db); await handleConvert(ctx);
expect(ctx.replies[0]).toContain("Insufficient USD"); expect(ctx.replies[0]).toContain("coming soon");
}); });
}); });
@@ -218,17 +164,17 @@ describe("trading/handlers", () => {
expect(ctx.replies[0]).toContain("Total value:"); 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 { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = emptyPortfolio(); const p = emptyPortfolio();
p.currency.VND = 5000000; p.currency.VND = 5000000;
p.crypto.BTC = 0.01; p.assets.TCB = 10;
p.totalvnd = 20000000; p.totalvnd = 10000000;
await savePortfolio(db, 42, p); await savePortfolio(db, 42, p);
const ctx = makeCtx(""); const ctx = makeCtx("");
await handleStats(ctx, db); await handleStats(ctx, db);
expect(ctx.replies[0]).toContain("BTC"); expect(ctx.replies[0]).toContain("TCB");
expect(ctx.replies[0]).toContain("P&L:"); expect(ctx.replies[0]).toContain("P&L:");
}); });
}); });

View File

@@ -12,7 +12,6 @@ import {
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
describe("trading/portfolio", () => { describe("trading/portfolio", () => {
/** @type {import("../../../src/db/kv-store-interface.js").KVStore} */
let db; let db;
beforeEach(() => { beforeEach(() => {
@@ -22,10 +21,8 @@ describe("trading/portfolio", () => {
describe("emptyPortfolio", () => { describe("emptyPortfolio", () => {
it("returns correct shape", () => { it("returns correct shape", () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
expect(p.currency).toEqual({ VND: 0, USD: 0 }); expect(p.currency).toEqual({ VND: 0 });
expect(p.stock).toEqual({}); expect(p.assets).toEqual({});
expect(p.crypto).toEqual({});
expect(p.others).toEqual({});
expect(p.totalvnd).toBe(0); expect(p.totalvnd).toBe(0);
}); });
}); });
@@ -35,24 +32,37 @@ describe("trading/portfolio", () => {
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.totalvnd).toBe(0);
expect(p.assets).toEqual({});
}); });
it("round-trips saved data", async () => { it("round-trips saved data", async () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
p.currency.VND = 5000000; p.currency.VND = 5000000;
p.assets.TCB = 10;
p.totalvnd = 5000000; p.totalvnd = 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.totalvnd).toBe(5000000); expect(loaded.totalvnd).toBe(5000000);
}); });
it("fills missing category keys on load (migration-safe)", async () => { it("migrates old 4-category format to flat assets", async () => {
// simulate old data missing 'others' key // simulate old format with stock/crypto/others
await db.putJSON("user:123", { currency: { VND: 100 }, stock: {}, crypto: {} }); 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); const p = await getPortfolio(db, 123);
expect(p.others).toEqual({}); expect(p.assets.TCB).toBe(10);
expect(p.totalvnd).toBe(0); 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); const result = deductCurrency(p, "VND", 5000);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.balance).toBe(1000); expect(result.balance).toBe(1000);
expect(p.currency.VND).toBe(1000); // unchanged
}); });
}); });
describe("addAsset / deductAsset", () => { describe("addAsset / deductAsset", () => {
it("adds crypto asset", () => { it("adds asset to flat map", () => {
const p = emptyPortfolio();
addAsset(p, "BTC", 0.5);
expect(p.crypto.BTC).toBe(0.5);
});
it("adds stock asset", () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
addAsset(p, "TCB", 10); addAsset(p, "TCB", 10);
expect(p.stock.TCB).toBe(10); expect(p.assets.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", () => { it("accumulates on repeated add", () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
addAsset(p, "BTC", 0.1); addAsset(p, "TCB", 10);
addAsset(p, "BTC", 0.2); addAsset(p, "TCB", 5);
expect(p.crypto.BTC).toBeCloseTo(0.3); expect(p.assets.TCB).toBe(15);
}); });
it("deducts asset when sufficient", () => { it("deducts asset when sufficient", () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
p.crypto.BTC = 1.0; p.assets.TCB = 10;
const result = deductAsset(p, "BTC", 0.3); const result = deductAsset(p, "TCB", 3);
expect(result.ok).toBe(true); 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", () => { it("removes key when deducted to zero", () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
p.crypto.BTC = 0.5; p.assets.TCB = 5;
deductAsset(p, "BTC", 0.5); deductAsset(p, "TCB", 5);
expect(p.crypto.BTC).toBeUndefined(); expect(p.assets.TCB).toBeUndefined();
}); });
it("rejects deduction when insufficient", () => { it("rejects deduction when insufficient", () => {
const p = emptyPortfolio(); const p = emptyPortfolio();
p.crypto.BTC = 0.1; p.assets.TCB = 3;
const result = deductAsset(p, "BTC", 0.5); const result = deductAsset(p, "TCB", 10);
expect(result.ok).toBe(false); 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 p = emptyPortfolio();
const result = deductAsset(p, "NOPE", 1); const result = deductAsset(p, "NOPE", 1);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.held).toBe(0);
}); });
}); });
}); });

View File

@@ -1,46 +1,59 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import { createStore } from "../../../src/db/create-store.js";
CURRENCIES, import { comingSoonMessage, resolveSymbol } from "../../../src/modules/trading/symbols.js";
SYMBOLS, import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
getSymbol,
listSymbols, /** Stub global.fetch to return TCBS-like response */
} from "../../../src/modules/trading/symbols.js"; function stubFetch(hasData = true) {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(hasData ? { data: [{ close: 25 }] } : { data: [] }),
}),
);
}
describe("trading/symbols", () => { describe("trading/symbols", () => {
it("SYMBOLS has 9 entries across 3 categories", () => { let db;
expect(Object.keys(SYMBOLS)).toHaveLength(9);
const cats = new Set(Object.values(SYMBOLS).map((s) => s.category)); beforeEach(() => {
expect(cats).toEqual(new Set(["crypto", "stock", "others"])); db = createStore("trading", { KV: makeFakeKv() });
vi.restoreAllMocks();
}); });
it("getSymbol is case-insensitive", () => { it("resolves a valid VN stock ticker via TCBS", async () => {
const btc = getSymbol("btc"); stubFetch();
expect(btc).toBeDefined(); const result = await resolveSymbol(db, "TCB");
expect(btc.symbol).toBe("BTC"); expect(result).toEqual({ symbol: "TCB", category: "stock", label: "TCB" });
expect(btc.category).toBe("crypto"); expect(global.fetch).toHaveBeenCalledOnce();
expect(getSymbol("Tcb").symbol).toBe("TCB");
}); });
it("getSymbol returns undefined for unknown symbols", () => { it("caches resolved symbol in KV", async () => {
expect(getSymbol("NOPE")).toBeUndefined(); stubFetch();
expect(getSymbol("")).toBeUndefined(); await resolveSymbol(db, "TCB");
expect(getSymbol(null)).toBeUndefined(); // second call should use cache, not fetch
await resolveSymbol(db, "TCB");
expect(global.fetch).toHaveBeenCalledOnce();
}); });
it("CURRENCIES contains VND and USD", () => { it("is case-insensitive", async () => {
expect(CURRENCIES.has("VND")).toBe(true); stubFetch();
expect(CURRENCIES.has("USD")).toBe(true); const result = await resolveSymbol(db, "tcb");
expect(CURRENCIES.has("EUR")).toBe(false); expect(result.symbol).toBe("TCB");
}); });
it("listSymbols returns grouped output", () => { it("returns null for invalid ticker", async () => {
const out = listSymbols(); stubFetch(false);
expect(out).toContain("Crypto:"); const result = await resolveSymbol(db, "NOPE");
expect(out).toContain("BTC — Bitcoin"); expect(result).toBeNull();
expect(out).toContain("Stocks:"); });
expect(out).toContain("TCB — Techcombank");
expect(out).toContain("Others:"); it("returns null for empty input", async () => {
expect(out).toContain("GOLD — Gold"); const result = await resolveSymbol(db, "");
expect(result).toBeNull();
});
it("comingSoonMessage returns string", () => {
expect(comingSoonMessage()).toContain("coming soon");
}); });
}); });