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
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
| Command | Action |
|---------|--------|
| `/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_sell <amount> <symbol>` | Sell holdings back to VND at market price. |
| `/trade_convert <amount> <from> <to>` | Convert between currencies at real BIDV bid/ask rates. |
| `/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_convert` | Currency exchange (coming soon). |
| `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. |
## Supported Symbols
## Symbol Resolution
| Symbol | Category | Source | Label |
|--------|----------|--------|-------|
| 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) |
Symbols are **resolved dynamically** — no hardcoded registry. When a user buys a ticker:
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
@@ -34,74 +28,55 @@ KV namespace prefix: `trading:`
| Key | Type | Description |
|-----|------|-------------|
| `user:<telegramId>` | JSON | Per-user portfolio (balances + holdings) |
| `prices:latest` | JSON | Cached merged prices from all APIs |
| `user:<telegramId>` | JSON | Per-user portfolio |
| `sym:<TICKER>` | JSON | Cached symbol resolution |
| `forex:latest` | JSON | Cached BIDV forex rates |
### Schema: `user:<telegramId>`
```json
{
"currency": { "VND": 5000000, "USD": 100 },
"stock": { "TCB": 10, "FPT": 5 },
"crypto": { "BTC": 0.005, "ETH": 1.2 },
"others": { "GOLD": 0.1 },
"currency": { "VND": 5000000 },
"assets": { "TCB": 10, "FPT": 5, "VNM": 100 },
"totalvnd": 10000000
}
```
- `currency` — fiat balances (VND, USD)
- `stock` / `crypto` / `others` — asset quantities keyed by symbol
- `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)
- VND is the sole settlement currency — buy/sell deducts/adds VND
- Empty categories are `{}`, not absent — migration-safe loading fills missing keys
- Migrates old 4-category format (`stock`/`crypto`/`others`) automatically on load
### Schema: `prices:latest`
### Schema: `sym:<TICKER>`
```json
{
"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 }
}
{ "symbol": "TCB", "category": "stock", "label": "TCB" }
```
- `ts` — Unix epoch milliseconds of last fetch
- All prices in VND per unit
- Cache TTL: 60 seconds (stale fallback up to 5 minutes)
Cached permanently after first successful TCBS lookup.
## 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 |
|-----|---------|------|-----------|
| 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.
Prices are fetched on demand per symbol (not batch-cached), since any ticker can be queried dynamically.
## File Layout
```
src/modules/trading/
├── index.js — module entry, wires handlers to commands
├── symbols.js — hardcoded symbol registry (9 assets, 2 currencies)
├── format.js — VND/USD/crypto/stock/P&L formatters
├── portfolio.js — per-user KV read/write, balance checks
├── prices.js — API fetching + 60s cache
├── symbols.js — dynamic symbol resolution via TCBS + KV cache
├── format.js — VND/stock number formatters
├── portfolio.js — per-user KV read/write, flat assets map
├── prices.js — TCBS stock price fetch + BIDV forex (for future use)
├── handlers.js — topup/buy/sell/convert handlers
└── stats-handler.js — stats/P&L breakdown handler
```
## Adding a Symbol
## Future
Add one line to `symbols.js`:
```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.
- Crypto (CoinGecko), gold (PAX Gold), currency exchange (BIDV bid/ask rates)
- Dynamic symbol resolution will extend to CoinGecko search for crypto

View File

@@ -1,9 +1,10 @@
/**
* @file Command handler implementations for the trading module.
* 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 {
addAsset,
addCurrency,
@@ -12,8 +13,8 @@ import {
getPortfolio,
savePortfolio,
} from "./portfolio.js";
import { getForexBidAsk, getPrice } from "./prices.js";
import { CURRENCIES, getSymbol, listSymbols } from "./symbols.js";
import { getStockPrice } from "./prices.js";
import { comingSoonMessage, resolveSymbol } from "./symbols.js";
function uid(ctx) {
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)}`);
}
/** /trade_buy <amount> <symbol> */
/** /trade_buy <amount> <symbol> — buy VN stock at market price */
export async function handleBuy(ctx, db) {
const args = parseArgs(ctx);
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]);
if (!Number.isFinite(amount) || amount <= 0)
return ctx.reply("Amount must be a positive number.");
const info = getSymbol(args[1]);
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.");
if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
const info = await resolveSymbol(db, args[1]);
if (!info)
return ctx.reply(`Unknown stock ticker "${args[1].toUpperCase()}".\n${comingSoonMessage()}`);
let price;
try {
price = await getPrice(db, info.symbol);
price = await getStockPrice(info.symbol);
} catch {
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);
await savePortfolio(db, uid(ctx), p);
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
await ctx.reply(`Bought ${qty} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`);
await ctx.reply(
`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) {
const args = parseArgs(ctx);
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]);
if (!Number.isFinite(amount) || amount <= 0)
return ctx.reply("Amount must be a positive number.");
const info = getSymbol(args[1]);
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.");
if (!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 result = deductAsset(p, info.symbol, amount);
if (!result.ok) {
const qty = info.category === "stock" ? formatStock(result.held) : formatCrypto(result.held);
return ctx.reply(`Insufficient ${info.symbol}. You have: ${qty}`);
}
const result = deductAsset(p, symbol, amount);
if (!result.ok) return ctx.reply(`Insufficient ${symbol}. You have: ${formatStock(result.held)}`);
let price;
try {
price = await getPrice(db, info.symbol);
price = await getStockPrice(symbol);
} catch {
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;
addCurrency(p, "VND", revenue);
await savePortfolio(db, uid(ctx), p);
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
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 */
export async function handleConvert(ctx, db) {
const args = parseArgs(ctx);
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}%)`,
);
/** /trade_convert — disabled, coming soon */
export async function handleConvert(ctx) {
await ctx.reply(`Currency exchange is not available yet.\n${comingSoonMessage()}`);
}

View File

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

View File

@@ -1,27 +1,26 @@
/**
* @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 }
* Assets are stored in a flat map — category is derived from symbol resolution.
*/
import { getSymbol } from "./symbols.js";
/**
* @typedef {Object} Portfolio
* @property {{ [currency: string]: number }} currency
* @property {{ [symbol: string]: number }} stock
* @property {{ [symbol: string]: number }} crypto
* @property {{ [symbol: string]: number }} others
* @property {{ [symbol: string]: number }} assets
* @property {number} totalvnd
*/
/** @returns {Portfolio} */
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.
* 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 {number|string} userId
* @returns {Promise<Portfolio>}
@@ -29,14 +28,22 @@ export function emptyPortfolio() {
export async function getPortfolio(db, userId) {
const raw = await db.getJSON(`user:${userId}`);
if (!raw) return emptyPortfolio();
// ensure all expected keys exist
const p = emptyPortfolio();
p.currency = { ...p.currency, ...raw.currency };
p.stock = { ...raw.stock };
p.crypto = { ...raw.crypto };
p.others = { ...raw.others };
p.totalvnd = raw.totalvnd ?? 0;
return p;
// 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,
};
}
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 {string} symbol
* @param {number} qty
*/
export function addAsset(p, symbol, qty) {
const info = getSymbol(symbol);
if (!info) return;
const cat = info.category;
p[cat][symbol] = (p[cat][symbol] || 0) + qty;
p.assets[symbol] = (p.assets[symbol] || 0) + qty;
}
/**
@@ -95,13 +99,10 @@ export function addAsset(p, symbol, qty) {
* @returns {{ ok: boolean, held: number }}
*/
export function deductAsset(p, symbol, qty) {
const info = getSymbol(symbol);
if (!info) return { ok: false, held: 0 };
const cat = info.category;
const held = p[cat][symbol] || 0;
const held = p.assets[symbol] || 0;
if (held < qty) return { ok: false, held };
const remaining = held - qty;
if (remaining === 0) delete p[cat][symbol];
else p[cat][symbol] = remaining;
if (remaining === 0) delete p.assets[symbol];
else p.assets[symbol] = remaining;
return { ok: true, held: remaining };
}

View File

@@ -1,57 +1,27 @@
/**
* @file Price fetching — CoinGecko (crypto+gold), TCBS (VN stocks), ER-API (forex).
* Caches merged result in KV for 60s to avoid API spam.
* @file Price fetching — TCBS (VN stocks) + BIDV (forex).
* Single-stock price fetch on demand. Forex rates cached for 60s.
*/
import { SYMBOLS } from "./symbols.js";
const CACHE_KEY = "prices:latest";
const FOREX_CACHE_KEY = "forex:latest";
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() {
const ids = Object.values(SYMBOLS)
.filter((s) => s.category === "crypto" || s.category === "others")
.map((s) => s.apiId)
.join(",");
const res = await fetch(
`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");
/**
* Fetch current VND price for a VN stock ticker via TCBS.
* Returns close price * 1000 (TCBS convention).
* @param {string} ticker — uppercase, e.g. "TCB"
* @returns {Promise<number|null>}
*/
export async function fetchStockPrice(ticker) {
const to = Math.floor(Date.now() / 1000);
const results = await Promise.allSettled(
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 url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${ticker}&type=stock&resolution=D&countBack=1&to=${to}`;
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 close = json?.data?.[0]?.close;
if (close == null) throw new Error(`TCBS ${entry.apiId} no close`);
return { sym, price: close * 1000 };
}),
);
const stock = {};
for (const r of results) {
if (r.status === "fulfilled") stock[r.value.sym] = r.value.price;
}
return stock;
if (close == null) return null;
return close * 1000;
}
/** Forex rates via BIDV public API — returns real buy/sell rates */
@@ -61,7 +31,6 @@ async function fetchForex() {
const json = await res.json();
const usd = json?.data?.find((r) => r.currency === "USD");
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 buy = parse(usd.muaCk);
const sell = parse(usd.ban);
@@ -72,82 +41,58 @@ async function fetchForex() {
}
/**
* Fetch all prices in parallel, merge, cache in KV.
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
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"
* Get VND price for a stock symbol.
* @param {string} symbol — uppercase ticker
* @returns {Promise<number|null>}
*/
export async function getPrice(db, symbol) {
const info = SYMBOLS[symbol];
if (!info) return null;
const prices = await getPrices(db);
return prices[info.category]?.[symbol] ?? null;
export async function getStockPrice(symbol) {
return fetchStockPrice(symbol);
}
/**
* 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).
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {string} currency — "VND" or "USD"
* @param {string} currency
* @returns {Promise<number|null>}
*/
export async function getForexRate(db, currency) {
if (currency === "VND") return 1;
const prices = await getPrices(db);
return prices.forex?.[currency]?.mid ?? null;
const rates = await getForexRates(db);
return rates[currency]?.mid ?? null;
}
/**
* 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 {string} currency
* @returns {Promise<{ buy: number, sell: number }|null>}
*/
export async function getForexBidAsk(db, currency) {
if (currency === "VND") return null;
const prices = await getPrices(db);
const rate = prices.forex?.[currency];
const rates = await getForexRates(db);
const rate = rates[currency];
if (!rate?.buy || !rate?.sell) return null;
return { buy: rate.buy, sell: rate.sell };
}

View File

@@ -1,59 +1,46 @@
/**
* @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 { getPrices } from "./prices.js";
import { getStockPrice } from "./prices.js";
/** /trade_stats — show full portfolio valued in VND with P&L */
export async function handleStats(ctx, db) {
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"];
let totalValue = 0;
// currencies
const currLines = [];
for (const [cur, bal] of Object.entries(p.currency)) {
if (bal === 0) continue;
const rate = cur === "VND" ? 1 : (prices.forex?.[cur]?.mid ?? 0);
const vndVal = bal * rate;
totalValue += vndVal;
currLines.push(
cur === "VND"
? ` VND: ${formatVND(bal)}`
: ` ${cur}: ${formatCurrency(bal, cur)} (~${formatVND(vndVal)})`,
);
// VND balance
const vnd = p.currency.VND || 0;
if (vnd > 0) {
totalValue += vnd;
lines.push(`VND: ${formatVND(vnd)}`);
}
if (currLines.length) lines.push("Currency:", ...currLines);
// asset categories
for (const [catName, catLabel] of [
["stock", "Stocks"],
["crypto", "Crypto"],
["others", "Others"],
]) {
const catLines = [];
for (const [sym, qty] of Object.entries(p[catName])) {
// stock assets
const assetEntries = Object.entries(p.assets);
if (assetEntries.length > 0) {
lines.push("\nStocks:");
for (const [sym, qty] of assetEntries) {
if (qty === 0) continue;
const price = prices[catName]?.[sym];
let price;
try {
price = await getStockPrice(sym);
} catch {
price = null;
}
if (price == null) {
catLines.push(` ${sym}: ${qty} (no price)`);
lines.push(` ${sym} x${formatStock(qty)} (no price)`);
continue;
}
const val = qty * price;
totalValue += val;
const fmtQty = catName === "stock" ? formatStock(qty) : formatCrypto(qty);
catLines.push(` ${sym} x${fmtQty} @ ${formatVND(price)} = ${formatVND(val)}`);
lines.push(` ${sym} x${formatStock(qty)} @ ${formatVND(price)} = ${formatVND(val)}`);
}
if (catLines.length) lines.push(`\n${catLabel}:`, ...catLines);
}
lines.push(`\nTotal value: ${formatVND(totalValue)}`);

View File

@@ -1,53 +1,52 @@
/**
* @file Symbol registry — hardcoded list of tradable assets + fiat currencies.
* Adding a new asset = one line here, no logic changes elsewhere.
* @file Symbol resolution — dynamically resolves stock tickers via TCBS API.
* 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 */
/** @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"]));
const COMING_SOON = "Crypto, gold & currency exchange coming soon!";
/**
* Case-insensitive symbol lookup.
* @param {string} name
* @returns {SymbolEntry & { symbol: string } | undefined}
* @typedef {Object} ResolvedSymbol
* @property {string} symbol — uppercase ticker
* @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();
const entry = SYMBOLS[key];
return entry ? { ...entry, symbol: key } : undefined;
/**
* Resolve a ticker to a symbol entry. Checks KV cache first, then queries TCBS.
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @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}
*/
export function listSymbols() {
const groups = { crypto: [], stock: [], others: [] };
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");
export function comingSoonMessage() {
return COMING_SOON;
}

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");
});
});