diff --git a/src/modules/trading/README.md b/src/modules/trading/README.md index 5e1cf9f..5e6d3e4 100644 --- a/src/modules/trading/README.md +++ b/src/modules/trading/README.md @@ -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 ` | Add VND to account. Tracks cumulative invested via `totalvnd`. | -| `/trade_buy ` | Buy at market price, deducting VND. Stocks must be integer quantities. | -| `/trade_sell ` | Sell holdings back to VND at market price. | -| `/trade_convert ` | Convert between currencies at real BIDV bid/ask rates. | +| `/trade_buy ` | Buy VN stock at market price, deducting VND. Integer quantities only. | +| `/trade_sell ` | Sell stock holdings back to VND at market price. | +| `/trade_convert` | Currency exchange (coming soon). | | `/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:`) → 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:` | JSON | Per-user portfolio (balances + holdings) | -| `prices:latest` | JSON | Cached merged prices from all APIs | +| `user:` | JSON | Per-user portfolio | +| `sym:` | JSON | Cached symbol resolution | +| `forex:latest` | JSON | Cached BIDV forex rates | ### Schema: `user:` ```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:` ```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 diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js index ad650fe..a9a0a8c 100644 --- a/src/modules/trading/handlers.js +++ b/src/modules/trading/handlers.js @@ -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 */ +/** /trade_buy — 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 \nExample: /trade_buy 0.01 BTC"); + return usageReply(ctx, "/trade_buy \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 */ +/** /trade_sell — 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 \nExample: /trade_sell 0.01 BTC"); + return usageReply(ctx, "/trade_sell \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 — with bid/ask spread */ -export async function handleConvert(ctx, db) { - const args = parseArgs(ctx); - if (args.length < 3) - return usageReply( - ctx, - "/trade_convert \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()}`); } diff --git a/src/modules/trading/index.js b/src/modules/trading/index.js index 7744d87..36c4065 100644 --- a/src/modules/trading/index.js +++ b/src/modules/trading/index.js @@ -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", diff --git a/src/modules/trading/portfolio.js b/src/modules/trading/portfolio.js index 967b9b4..ac0bf5a 100644 --- a/src/modules/trading/portfolio.js +++ b/src/modules/trading/portfolio.js @@ -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} @@ -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 }; } diff --git a/src/modules/trading/prices.js b/src/modules/trading/prices.js index 10bf5cf..5ea1440 100644 --- a/src/modules/trading/prices.js +++ b/src/modules/trading/prices.js @@ -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} + */ +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} */ -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} */ 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 }; } diff --git a/src/modules/trading/stats-handler.js b/src/modules/trading/stats-handler.js index 506368c..03dc9ea 100644 --- a/src/modules/trading/stats-handler.js +++ b/src/modules/trading/stats-handler.js @@ -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)}`); diff --git a/src/modules/trading/symbols.js b/src/modules/trading/symbols.js index 1392ab8..3a3b90a 100644 --- a/src/modules/trading/symbols.js +++ b/src/modules/trading/symbols.js @@ -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>} */ -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} 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; } diff --git a/tests/modules/trading/handlers.test.js b/tests/modules/trading/handlers.test.js index a6ad83f..8422b94 100644 --- a/tests/modules/trading/handlers.test.js +++ b/tests/modules/trading/handlers.test.js @@ -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:"); }); }); diff --git a/tests/modules/trading/portfolio.test.js b/tests/modules/trading/portfolio.test.js index ee8f1ac..2af3c4b 100644 --- a/tests/modules/trading/portfolio.test.js +++ b/tests/modules/trading/portfolio.test.js @@ -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); }); }); }); diff --git a/tests/modules/trading/symbols.test.js b/tests/modules/trading/symbols.test.js index ffa5955..08edece 100644 --- a/tests/modules/trading/symbols.test.js +++ b/tests/modules/trading/symbols.test.js @@ -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"); }); });