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 res = await fetch(url);
if (!res.ok) throw new Error(`TCBS ${entry.apiId} ${res.status}`);
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;
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) return null;
const json = await res.json();
const close = json?.data?.[0]?.close;
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;
}