mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
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:
@@ -1,32 +1,26 @@
|
|||||||
# Trading Module
|
# Trading Module
|
||||||
|
|
||||||
Paper-trading system where each Telegram user manages a virtual portfolio.
|
Paper-trading system where each Telegram user manages a virtual portfolio. Currently supports **VN stocks only** — crypto, gold, and currency exchange coming later.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Action |
|
| Command | Action |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| `/trade_topup <amount>` | Add VND to account. Tracks cumulative invested via `totalvnd`. |
|
| `/trade_topup <amount>` | Add VND to account. Tracks cumulative invested via `totalvnd`. |
|
||||||
| `/trade_buy <amount> <symbol>` | Buy at market price, deducting VND. Stocks must be integer quantities. |
|
| `/trade_buy <qty> <TICKER>` | Buy VN stock at market price, deducting VND. Integer quantities only. |
|
||||||
| `/trade_sell <amount> <symbol>` | Sell holdings back to VND at market price. |
|
| `/trade_sell <qty> <TICKER>` | Sell stock holdings back to VND at market price. |
|
||||||
| `/trade_convert <amount> <from> <to>` | Convert between currencies at real BIDV bid/ask rates. |
|
| `/trade_convert` | Currency exchange (coming soon). |
|
||||||
| `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. |
|
| `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. |
|
||||||
|
|
||||||
## Supported Symbols
|
## Symbol Resolution
|
||||||
|
|
||||||
| Symbol | Category | Source | Label |
|
Symbols are **resolved dynamically** — no hardcoded registry. When a user buys a ticker:
|
||||||
|--------|----------|--------|-------|
|
|
||||||
| BTC | crypto | CoinGecko | Bitcoin |
|
|
||||||
| ETH | crypto | CoinGecko | Ethereum |
|
|
||||||
| SOL | crypto | CoinGecko | Solana |
|
|
||||||
| TCB | stock | TCBS | Techcombank |
|
|
||||||
| VPB | stock | TCBS | VPBank |
|
|
||||||
| FPT | stock | TCBS | FPT Corp |
|
|
||||||
| VNM | stock | TCBS | Vinamilk |
|
|
||||||
| HPG | stock | TCBS | Hoa Phat |
|
|
||||||
| GOLD | others | CoinGecko (PAX Gold) | Gold (troy oz) |
|
|
||||||
|
|
||||||
Currencies: VND, USD.
|
1. Check KV cache (`sym:<TICKER>`) → if cached, use it
|
||||||
|
2. Query TCBS API to verify the ticker exists and has price data
|
||||||
|
3. Cache the resolution permanently in KV
|
||||||
|
|
||||||
|
Any valid VN stock ticker on TCBS "just works" without code changes.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
@@ -34,74 +28,55 @@ KV namespace prefix: `trading:`
|
|||||||
|
|
||||||
| Key | Type | Description |
|
| Key | Type | Description |
|
||||||
|-----|------|-------------|
|
|-----|------|-------------|
|
||||||
| `user:<telegramId>` | JSON | Per-user portfolio (balances + holdings) |
|
| `user:<telegramId>` | JSON | Per-user portfolio |
|
||||||
| `prices:latest` | JSON | Cached merged prices from all APIs |
|
| `sym:<TICKER>` | JSON | Cached symbol resolution |
|
||||||
|
| `forex:latest` | JSON | Cached BIDV forex rates |
|
||||||
|
|
||||||
### Schema: `user:<telegramId>`
|
### Schema: `user:<telegramId>`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"currency": { "VND": 5000000, "USD": 100 },
|
"currency": { "VND": 5000000 },
|
||||||
"stock": { "TCB": 10, "FPT": 5 },
|
"assets": { "TCB": 10, "FPT": 5, "VNM": 100 },
|
||||||
"crypto": { "BTC": 0.005, "ETH": 1.2 },
|
|
||||||
"others": { "GOLD": 0.1 },
|
|
||||||
"totalvnd": 10000000
|
"totalvnd": 10000000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `currency` — fiat balances (VND, USD)
|
- `currency` — fiat balances (VND only for now)
|
||||||
- `stock` / `crypto` / `others` — asset quantities keyed by symbol
|
- `assets` — flat map of stock quantities keyed by ticker
|
||||||
- `totalvnd` — cumulative VND value of all top-ups (cost basis for P&L)
|
- `totalvnd` — cumulative VND value of all top-ups (cost basis for P&L)
|
||||||
- VND is the sole settlement currency — buy/sell deducts/adds VND
|
- Migrates old 4-category format (`stock`/`crypto`/`others`) automatically on load
|
||||||
- Empty categories are `{}`, not absent — migration-safe loading fills missing keys
|
|
||||||
|
|
||||||
### Schema: `prices:latest`
|
### Schema: `sym:<TICKER>`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "symbol": "TCB", "category": "stock", "label": "TCB" }
|
||||||
"ts": 1713100000000,
|
|
||||||
"crypto": { "BTC": 1500000000, "ETH": 50000000, "SOL": 3000000 },
|
|
||||||
"stock": { "TCB": 25000, "VPB": 18000, "FPT": 120000, "VNM": 70000, "HPG": 28000 },
|
|
||||||
"forex": { "USD": { "mid": 25400, "buy": 25200, "sell": 25600 } },
|
|
||||||
"others": { "GOLD": 72000000 }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ts` — Unix epoch milliseconds of last fetch
|
Cached permanently after first successful TCBS lookup.
|
||||||
- All prices in VND per unit
|
|
||||||
- Cache TTL: 60 seconds (stale fallback up to 5 minutes)
|
|
||||||
|
|
||||||
## Price Sources
|
## Price Source
|
||||||
|
|
||||||
Three free APIs fetched in parallel, cached in KV for 60 seconds:
|
| API | Purpose | Auth |
|
||||||
|
|-----|---------|------|
|
||||||
|
| TCBS `/stock-insight/v1/stock/bars-long-term` | VN stock close price (× 1000) | None |
|
||||||
|
|
||||||
| API | Purpose | Auth | Rate Limit |
|
Prices are fetched on demand per symbol (not batch-cached), since any ticker can be queried dynamically.
|
||||||
|-----|---------|------|-----------|
|
|
||||||
| CoinGecko `/api/v3/simple/price` | Crypto + gold prices in VND | None | 30 calls/min (free) |
|
|
||||||
| TCBS `/stock-insight/v1/stock/bars-long-term` | Vietnam stock close prices (× 1000) | None | Unofficial |
|
|
||||||
| BIDV `/ServicesBIDV/ExchangeDetailServlet` | USD/VND buy/sell rates | None | Unofficial |
|
|
||||||
|
|
||||||
On partial API failure, available data is returned. On total failure, stale cache up to 5 minutes old is used before surfacing an error.
|
|
||||||
|
|
||||||
## File Layout
|
## File Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/modules/trading/
|
src/modules/trading/
|
||||||
├── index.js — module entry, wires handlers to commands
|
├── index.js — module entry, wires handlers to commands
|
||||||
├── symbols.js — hardcoded symbol registry (9 assets, 2 currencies)
|
├── symbols.js — dynamic symbol resolution via TCBS + KV cache
|
||||||
├── format.js — VND/USD/crypto/stock/P&L formatters
|
├── format.js — VND/stock number formatters
|
||||||
├── portfolio.js — per-user KV read/write, balance checks
|
├── portfolio.js — per-user KV read/write, flat assets map
|
||||||
├── prices.js — API fetching + 60s cache
|
├── prices.js — TCBS stock price fetch + BIDV forex (for future use)
|
||||||
├── handlers.js — topup/buy/sell/convert handlers
|
├── handlers.js — topup/buy/sell/convert handlers
|
||||||
└── stats-handler.js — stats/P&L breakdown handler
|
└── stats-handler.js — stats/P&L breakdown handler
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding a Symbol
|
## Future
|
||||||
|
|
||||||
Add one line to `symbols.js`:
|
- Crypto (CoinGecko), gold (PAX Gold), currency exchange (BIDV bid/ask rates)
|
||||||
|
- Dynamic symbol resolution will extend to CoinGecko search for crypto
|
||||||
```js
|
|
||||||
NEWSYM: { category: "crypto", apiId: "coingecko-id", label: "New Coin" },
|
|
||||||
```
|
|
||||||
|
|
||||||
For stocks, `apiId` is the TCBS ticker. For crypto/gold, `apiId` is the CoinGecko ID.
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file Command handler implementations for the trading module.
|
* @file Command handler implementations for the trading module.
|
||||||
* Each handler receives (ctx, db) — the grammY context and KV store.
|
* Each handler receives (ctx, db) — the grammY context and KV store.
|
||||||
|
* Currently only VN stocks are supported. Crypto/gold/convert coming later.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatCrypto, formatCurrency, formatStock, formatVND } from "./format.js";
|
import { formatStock, formatVND } from "./format.js";
|
||||||
import {
|
import {
|
||||||
addAsset,
|
addAsset,
|
||||||
addCurrency,
|
addCurrency,
|
||||||
@@ -12,8 +13,8 @@ import {
|
|||||||
getPortfolio,
|
getPortfolio,
|
||||||
savePortfolio,
|
savePortfolio,
|
||||||
} from "./portfolio.js";
|
} from "./portfolio.js";
|
||||||
import { getForexBidAsk, getPrice } from "./prices.js";
|
import { getStockPrice } from "./prices.js";
|
||||||
import { CURRENCIES, getSymbol, listSymbols } from "./symbols.js";
|
import { comingSoonMessage, resolveSymbol } from "./symbols.js";
|
||||||
|
|
||||||
function uid(ctx) {
|
function uid(ctx) {
|
||||||
return ctx.from?.id;
|
return ctx.from?.id;
|
||||||
@@ -43,22 +44,23 @@ export async function handleTopup(ctx, db) {
|
|||||||
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /trade_buy <amount> <symbol> */
|
/** /trade_buy <amount> <symbol> — buy VN stock at market price */
|
||||||
export async function handleBuy(ctx, db) {
|
export async function handleBuy(ctx, db) {
|
||||||
const args = parseArgs(ctx);
|
const args = parseArgs(ctx);
|
||||||
if (args.length < 2)
|
if (args.length < 2)
|
||||||
return usageReply(ctx, "/trade_buy <amount> <SYMBOL>\nExample: /trade_buy 0.01 BTC");
|
return usageReply(ctx, "/trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB");
|
||||||
const amount = Number(args[0]);
|
const amount = Number(args[0]);
|
||||||
if (!Number.isFinite(amount) || amount <= 0)
|
if (!Number.isFinite(amount) || amount <= 0)
|
||||||
return ctx.reply("Amount must be a positive number.");
|
return ctx.reply("Amount must be a positive number.");
|
||||||
const info = getSymbol(args[1]);
|
if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
|
||||||
if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`);
|
|
||||||
if (info.category === "stock" && !Number.isInteger(amount))
|
const info = await resolveSymbol(db, args[1]);
|
||||||
return ctx.reply("Stock quantities must be whole numbers.");
|
if (!info)
|
||||||
|
return ctx.reply(`Unknown stock ticker "${args[1].toUpperCase()}".\n${comingSoonMessage()}`);
|
||||||
|
|
||||||
let price;
|
let price;
|
||||||
try {
|
try {
|
||||||
price = await getPrice(db, info.symbol);
|
price = await getStockPrice(info.symbol);
|
||||||
} catch {
|
} catch {
|
||||||
return ctx.reply("Could not fetch price. Try again later.");
|
return ctx.reply("Could not fetch price. Try again later.");
|
||||||
}
|
}
|
||||||
@@ -74,94 +76,43 @@ export async function handleBuy(ctx, db) {
|
|||||||
}
|
}
|
||||||
addAsset(p, info.symbol, amount);
|
addAsset(p, info.symbol, amount);
|
||||||
await savePortfolio(db, uid(ctx), p);
|
await savePortfolio(db, uid(ctx), p);
|
||||||
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
|
await ctx.reply(
|
||||||
await ctx.reply(`Bought ${qty} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`);
|
`Bought ${formatStock(amount)} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /trade_sell <amount> <symbol> */
|
/** /trade_sell <amount> <symbol> — sell VN stock back to VND */
|
||||||
export async function handleSell(ctx, db) {
|
export async function handleSell(ctx, db) {
|
||||||
const args = parseArgs(ctx);
|
const args = parseArgs(ctx);
|
||||||
if (args.length < 2)
|
if (args.length < 2)
|
||||||
return usageReply(ctx, "/trade_sell <amount> <SYMBOL>\nExample: /trade_sell 0.01 BTC");
|
return usageReply(ctx, "/trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB");
|
||||||
const amount = Number(args[0]);
|
const amount = Number(args[0]);
|
||||||
if (!Number.isFinite(amount) || amount <= 0)
|
if (!Number.isFinite(amount) || amount <= 0)
|
||||||
return ctx.reply("Amount must be a positive number.");
|
return ctx.reply("Amount must be a positive number.");
|
||||||
const info = getSymbol(args[1]);
|
if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
|
||||||
if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`);
|
|
||||||
if (info.category === "stock" && !Number.isInteger(amount))
|
|
||||||
return ctx.reply("Stock quantities must be whole numbers.");
|
|
||||||
|
|
||||||
|
const symbol = args[1].toUpperCase();
|
||||||
const p = await getPortfolio(db, uid(ctx));
|
const p = await getPortfolio(db, uid(ctx));
|
||||||
const result = deductAsset(p, info.symbol, amount);
|
const result = deductAsset(p, symbol, amount);
|
||||||
if (!result.ok) {
|
if (!result.ok) return ctx.reply(`Insufficient ${symbol}. You have: ${formatStock(result.held)}`);
|
||||||
const qty = info.category === "stock" ? formatStock(result.held) : formatCrypto(result.held);
|
|
||||||
return ctx.reply(`Insufficient ${info.symbol}. You have: ${qty}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let price;
|
let price;
|
||||||
try {
|
try {
|
||||||
price = await getPrice(db, info.symbol);
|
price = await getStockPrice(symbol);
|
||||||
} catch {
|
} catch {
|
||||||
return ctx.reply("Could not fetch price. Try again later.");
|
return ctx.reply("Could not fetch price. Try again later.");
|
||||||
}
|
}
|
||||||
if (price == null) return ctx.reply(`No price available for ${info.symbol}.`);
|
if (price == null) return ctx.reply(`No price available for ${symbol}.`);
|
||||||
|
|
||||||
const revenue = amount * price;
|
const revenue = amount * price;
|
||||||
addCurrency(p, "VND", revenue);
|
addCurrency(p, "VND", revenue);
|
||||||
await savePortfolio(db, uid(ctx), p);
|
await savePortfolio(db, uid(ctx), p);
|
||||||
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Sold ${qty} ${info.symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
|
`Sold ${formatStock(amount)} ${symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /trade_convert <amount> <from> <to> — with bid/ask spread */
|
/** /trade_convert — disabled, coming soon */
|
||||||
export async function handleConvert(ctx, db) {
|
export async function handleConvert(ctx) {
|
||||||
const args = parseArgs(ctx);
|
await ctx.reply(`Currency exchange is not available yet.\n${comingSoonMessage()}`);
|
||||||
if (args.length < 3)
|
|
||||||
return usageReply(
|
|
||||||
ctx,
|
|
||||||
"/trade_convert <amount> <FROM> <TO>\nExample: /trade_convert 100 USD VND",
|
|
||||||
);
|
|
||||||
const amount = Number(args[0]);
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0)
|
|
||||||
return ctx.reply("Amount must be a positive number.");
|
|
||||||
const from = args[1].toUpperCase();
|
|
||||||
const to = args[2].toUpperCase();
|
|
||||||
if (!CURRENCIES.has(from) || !CURRENCIES.has(to))
|
|
||||||
return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`);
|
|
||||||
if (from === to) return ctx.reply("Cannot convert to the same currency.");
|
|
||||||
|
|
||||||
let rates;
|
|
||||||
try {
|
|
||||||
rates = await getForexBidAsk(db, "USD");
|
|
||||||
} catch {
|
|
||||||
return ctx.reply("Could not fetch forex rate. Try again later.");
|
|
||||||
}
|
|
||||||
if (!rates) return ctx.reply("Forex rate unavailable. Try again later.");
|
|
||||||
|
|
||||||
const p = await getPortfolio(db, uid(ctx));
|
|
||||||
const result = deductCurrency(p, from, amount);
|
|
||||||
if (!result.ok)
|
|
||||||
return ctx.reply(`Insufficient ${from}. Balance: ${formatCurrency(result.balance, from)}`);
|
|
||||||
|
|
||||||
// buy = bank buys USD (you sell USD → VND), sell = bank sells USD (you buy USD → pay VND)
|
|
||||||
let converted;
|
|
||||||
let rateUsed;
|
|
||||||
if (from === "VND" && to === "USD") {
|
|
||||||
// you're buying USD from bank → bank sells at higher price
|
|
||||||
converted = amount / rates.sell;
|
|
||||||
rateUsed = rates.sell;
|
|
||||||
} else {
|
|
||||||
// you're selling USD to bank → bank buys at lower price
|
|
||||||
converted = amount * rates.buy;
|
|
||||||
rateUsed = rates.buy;
|
|
||||||
}
|
|
||||||
|
|
||||||
addCurrency(p, to, converted);
|
|
||||||
await savePortfolio(db, uid(ctx), p);
|
|
||||||
const spread = (((rates.sell - rates.buy) / rates.buy) * 100).toFixed(2);
|
|
||||||
await ctx.reply(
|
|
||||||
`Converted ${formatCurrency(amount, from)} → ${formatCurrency(converted, to)}\nRate: ${formatVND(rateUsed)}/USD (buy: ${formatVND(rates.buy)}, sell: ${formatVND(rates.sell)}, spread: ${spread}%)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,20 +25,20 @@ const tradingModule = {
|
|||||||
{
|
{
|
||||||
name: "trade_buy",
|
name: "trade_buy",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
description: "Buy crypto/stock/gold at market price",
|
description: "Buy VN stock at market price",
|
||||||
handler: (ctx) => handleBuy(ctx, db),
|
handler: (ctx) => handleBuy(ctx, db),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trade_sell",
|
name: "trade_sell",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
description: "Sell holdings back to VND",
|
description: "Sell VN stock back to VND",
|
||||||
handler: (ctx) => handleSell(ctx, db),
|
handler: (ctx) => handleSell(ctx, db),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trade_convert",
|
name: "trade_convert",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
description: "Convert between currencies (bid/ask spread)",
|
description: "Currency exchange (coming soon)",
|
||||||
handler: (ctx) => handleConvert(ctx, db),
|
handler: (ctx) => handleConvert(ctx),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trade_stats",
|
name: "trade_stats",
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file Portfolio CRUD — per-user KV read/write and balance operations.
|
* @file Portfolio CRUD — per-user KV read/write and balance operations.
|
||||||
* All mutations are in-memory; caller must savePortfolio() to persist.
|
* All mutations are in-memory; caller must savePortfolio() to persist.
|
||||||
|
*
|
||||||
|
* Schema: { currency: { VND, USD }, assets: { SYMBOL: qty }, totalvnd }
|
||||||
|
* Assets are stored in a flat map — category is derived from symbol resolution.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSymbol } from "./symbols.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Portfolio
|
* @typedef {Object} Portfolio
|
||||||
* @property {{ [currency: string]: number }} currency
|
* @property {{ [currency: string]: number }} currency
|
||||||
* @property {{ [symbol: string]: number }} stock
|
* @property {{ [symbol: string]: number }} assets
|
||||||
* @property {{ [symbol: string]: number }} crypto
|
|
||||||
* @property {{ [symbol: string]: number }} others
|
|
||||||
* @property {number} totalvnd
|
* @property {number} totalvnd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @returns {Portfolio} */
|
/** @returns {Portfolio} */
|
||||||
export function emptyPortfolio() {
|
export function emptyPortfolio() {
|
||||||
return { currency: { VND: 0, USD: 0 }, stock: {}, crypto: {}, others: {}, totalvnd: 0 };
|
return { currency: { VND: 0 }, assets: {}, totalvnd: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load user portfolio from KV, or return empty if first-time user.
|
* Load user portfolio from KV, or return empty if first-time user.
|
||||||
* Ensures all category keys exist (migration-safe).
|
* Migrates old 4-category format to flat assets map.
|
||||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||||
* @param {number|string} userId
|
* @param {number|string} userId
|
||||||
* @returns {Promise<Portfolio>}
|
* @returns {Promise<Portfolio>}
|
||||||
@@ -29,14 +28,22 @@ export function emptyPortfolio() {
|
|||||||
export async function getPortfolio(db, userId) {
|
export async function getPortfolio(db, userId) {
|
||||||
const raw = await db.getJSON(`user:${userId}`);
|
const raw = await db.getJSON(`user:${userId}`);
|
||||||
if (!raw) return emptyPortfolio();
|
if (!raw) return emptyPortfolio();
|
||||||
// ensure all expected keys exist
|
|
||||||
const p = emptyPortfolio();
|
// migrate old format: merge stock/crypto/others into flat assets
|
||||||
p.currency = { ...p.currency, ...raw.currency };
|
if (raw.stock || raw.crypto || raw.others) {
|
||||||
p.stock = { ...raw.stock };
|
const assets = { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets };
|
||||||
p.crypto = { ...raw.crypto };
|
return {
|
||||||
p.others = { ...raw.others };
|
currency: { VND: 0, ...raw.currency },
|
||||||
p.totalvnd = raw.totalvnd ?? 0;
|
assets,
|
||||||
return p;
|
totalvnd: raw.totalvnd ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency: { VND: 0, ...raw.currency },
|
||||||
|
assets: raw.assets ?? {},
|
||||||
|
totalvnd: raw.totalvnd ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,16 +81,13 @@ export function deductCurrency(p, currency, amount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add asset (stock/crypto/others) to portfolio.
|
* Add asset to flat assets map.
|
||||||
* @param {Portfolio} p
|
* @param {Portfolio} p
|
||||||
* @param {string} symbol
|
* @param {string} symbol
|
||||||
* @param {number} qty
|
* @param {number} qty
|
||||||
*/
|
*/
|
||||||
export function addAsset(p, symbol, qty) {
|
export function addAsset(p, symbol, qty) {
|
||||||
const info = getSymbol(symbol);
|
p.assets[symbol] = (p.assets[symbol] || 0) + qty;
|
||||||
if (!info) return;
|
|
||||||
const cat = info.category;
|
|
||||||
p[cat][symbol] = (p[cat][symbol] || 0) + qty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,13 +99,10 @@ export function addAsset(p, symbol, qty) {
|
|||||||
* @returns {{ ok: boolean, held: number }}
|
* @returns {{ ok: boolean, held: number }}
|
||||||
*/
|
*/
|
||||||
export function deductAsset(p, symbol, qty) {
|
export function deductAsset(p, symbol, qty) {
|
||||||
const info = getSymbol(symbol);
|
const held = p.assets[symbol] || 0;
|
||||||
if (!info) return { ok: false, held: 0 };
|
|
||||||
const cat = info.category;
|
|
||||||
const held = p[cat][symbol] || 0;
|
|
||||||
if (held < qty) return { ok: false, held };
|
if (held < qty) return { ok: false, held };
|
||||||
const remaining = held - qty;
|
const remaining = held - qty;
|
||||||
if (remaining === 0) delete p[cat][symbol];
|
if (remaining === 0) delete p.assets[symbol];
|
||||||
else p[cat][symbol] = remaining;
|
else p.assets[symbol] = remaining;
|
||||||
return { ok: true, held: remaining };
|
return { ok: true, held: remaining };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* @file Price fetching — CoinGecko (crypto+gold), TCBS (VN stocks), ER-API (forex).
|
* @file Price fetching — TCBS (VN stocks) + BIDV (forex).
|
||||||
* Caches merged result in KV for 60s to avoid API spam.
|
* Single-stock price fetch on demand. Forex rates cached for 60s.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SYMBOLS } from "./symbols.js";
|
const FOREX_CACHE_KEY = "forex:latest";
|
||||||
|
|
||||||
const CACHE_KEY = "prices:latest";
|
|
||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
const STALE_LIMIT_MS = 300_000; // 5 min — max age for fallback
|
const STALE_LIMIT_MS = 300_000;
|
||||||
|
|
||||||
/** Crypto + gold via CoinGecko free API */
|
/**
|
||||||
async function fetchCrypto() {
|
* Fetch current VND price for a VN stock ticker via TCBS.
|
||||||
const ids = Object.values(SYMBOLS)
|
* Returns close price * 1000 (TCBS convention).
|
||||||
.filter((s) => s.category === "crypto" || s.category === "others")
|
* @param {string} ticker — uppercase, e.g. "TCB"
|
||||||
.map((s) => s.apiId)
|
* @returns {Promise<number|null>}
|
||||||
.join(",");
|
*/
|
||||||
const res = await fetch(
|
export async function fetchStockPrice(ticker) {
|
||||||
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=vnd`,
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(`CoinGecko ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
const crypto = {};
|
|
||||||
const others = {};
|
|
||||||
for (const [sym, entry] of Object.entries(SYMBOLS)) {
|
|
||||||
if (entry.category !== "crypto" && entry.category !== "others") continue;
|
|
||||||
const price = data[entry.apiId]?.vnd;
|
|
||||||
if (price == null) continue;
|
|
||||||
if (entry.category === "crypto") crypto[sym] = price;
|
|
||||||
else others[sym] = price;
|
|
||||||
}
|
|
||||||
return { crypto, others };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vietnam stock prices via TCBS public API (price in VND * 1000) */
|
|
||||||
async function fetchStocks() {
|
|
||||||
const tickers = Object.entries(SYMBOLS).filter(([, e]) => e.category === "stock");
|
|
||||||
const to = Math.floor(Date.now() / 1000);
|
const to = Math.floor(Date.now() / 1000);
|
||||||
const results = await Promise.allSettled(
|
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${ticker}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||||
tickers.map(async ([sym, entry]) => {
|
const res = await fetch(url);
|
||||||
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}`;
|
if (!res.ok) return null;
|
||||||
const res = await fetch(url);
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(`TCBS ${entry.apiId} ${res.status}`);
|
const close = json?.data?.[0]?.close;
|
||||||
const json = await res.json();
|
if (close == null) return null;
|
||||||
const close = json?.data?.[0]?.close;
|
return close * 1000;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forex rates via BIDV public API — returns real buy/sell rates */
|
/** Forex rates via BIDV public API — returns real buy/sell rates */
|
||||||
@@ -61,7 +31,6 @@ async function fetchForex() {
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const usd = json?.data?.find((r) => r.currency === "USD");
|
const usd = json?.data?.find((r) => r.currency === "USD");
|
||||||
if (!usd) throw new Error("BIDV missing USD rate");
|
if (!usd) throw new Error("BIDV missing USD rate");
|
||||||
// muaCk = bank buy (transfer), ban = bank sell — parse "26,141" → 26141
|
|
||||||
const parse = (s) => Number.parseFloat(String(s).replace(/,/g, ""));
|
const parse = (s) => Number.parseFloat(String(s).replace(/,/g, ""));
|
||||||
const buy = parse(usd.muaCk);
|
const buy = parse(usd.muaCk);
|
||||||
const sell = parse(usd.ban);
|
const sell = parse(usd.ban);
|
||||||
@@ -72,82 +41,58 @@ async function fetchForex() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all prices in parallel, merge, cache in KV.
|
* Get VND price for a stock symbol.
|
||||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
* @param {string} symbol — uppercase ticker
|
||||||
*/
|
|
||||||
export async function fetchPrices(db) {
|
|
||||||
const [cryptoRes, stockRes, forexRes] = await Promise.allSettled([
|
|
||||||
fetchCrypto(),
|
|
||||||
fetchStocks(),
|
|
||||||
fetchForex(),
|
|
||||||
]);
|
|
||||||
const merged = {
|
|
||||||
ts: Date.now(),
|
|
||||||
crypto: cryptoRes.status === "fulfilled" ? cryptoRes.value.crypto : {},
|
|
||||||
stock: stockRes.status === "fulfilled" ? stockRes.value : {},
|
|
||||||
forex: forexRes.status === "fulfilled" ? forexRes.value : {},
|
|
||||||
others: cryptoRes.status === "fulfilled" ? cryptoRes.value.others : {},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await db.putJSON(CACHE_KEY, merged);
|
|
||||||
} catch {
|
|
||||||
/* best effort cache write */
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache-first price retrieval. Returns cached if < 60s old, else fetches fresh.
|
|
||||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
|
||||||
*/
|
|
||||||
export async function getPrices(db) {
|
|
||||||
const cached = await db.getJSON(CACHE_KEY);
|
|
||||||
if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached;
|
|
||||||
try {
|
|
||||||
return await fetchPrices(db);
|
|
||||||
} catch {
|
|
||||||
// fallback to stale cache if < 5 min
|
|
||||||
if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached;
|
|
||||||
throw new Error("Could not fetch prices. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get VND price for a single symbol.
|
|
||||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
|
||||||
* @param {string} symbol — uppercase, e.g. "BTC"
|
|
||||||
* @returns {Promise<number|null>}
|
* @returns {Promise<number|null>}
|
||||||
*/
|
*/
|
||||||
export async function getPrice(db, symbol) {
|
export async function getStockPrice(symbol) {
|
||||||
const info = SYMBOLS[symbol];
|
return fetchStockPrice(symbol);
|
||||||
if (!info) return null;
|
}
|
||||||
const prices = await getPrices(db);
|
|
||||||
return prices[info.category]?.[symbol] ?? null;
|
/**
|
||||||
|
* Cache-first forex rate retrieval.
|
||||||
|
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||||
|
*/
|
||||||
|
async function getForexRates(db) {
|
||||||
|
const cached = await db.getJSON(FOREX_CACHE_KEY);
|
||||||
|
if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached;
|
||||||
|
try {
|
||||||
|
const forex = await fetchForex();
|
||||||
|
const data = { ts: Date.now(), ...forex };
|
||||||
|
try {
|
||||||
|
await db.putJSON(FOREX_CACHE_KEY, data);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached;
|
||||||
|
throw new Error("Could not fetch forex rates. Try again later.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mid-rate VND equivalent of 1 unit of currency (for stats display).
|
* Mid-rate VND equivalent of 1 unit of currency (for stats display).
|
||||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||||
* @param {string} currency — "VND" or "USD"
|
* @param {string} currency
|
||||||
* @returns {Promise<number|null>}
|
* @returns {Promise<number|null>}
|
||||||
*/
|
*/
|
||||||
export async function getForexRate(db, currency) {
|
export async function getForexRate(db, currency) {
|
||||||
if (currency === "VND") return 1;
|
if (currency === "VND") return 1;
|
||||||
const prices = await getPrices(db);
|
const rates = await getForexRates(db);
|
||||||
return prices.forex?.[currency]?.mid ?? null;
|
return rates[currency]?.mid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buy/sell forex rates for a currency.
|
* Buy/sell forex rates for a currency.
|
||||||
* buy = bank buys USD from you (you sell USD → get VND), sell = bank sells USD to you (you buy USD → pay VND).
|
|
||||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||||
* @param {string} currency
|
* @param {string} currency
|
||||||
* @returns {Promise<{ buy: number, sell: number }|null>}
|
* @returns {Promise<{ buy: number, sell: number }|null>}
|
||||||
*/
|
*/
|
||||||
export async function getForexBidAsk(db, currency) {
|
export async function getForexBidAsk(db, currency) {
|
||||||
if (currency === "VND") return null;
|
if (currency === "VND") return null;
|
||||||
const prices = await getPrices(db);
|
const rates = await getForexRates(db);
|
||||||
const rate = prices.forex?.[currency];
|
const rate = rates[currency];
|
||||||
if (!rate?.buy || !rate?.sell) return null;
|
if (!rate?.buy || !rate?.sell) return null;
|
||||||
return { buy: rate.buy, sell: rate.sell };
|
return { buy: rate.buy, sell: rate.sell };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* @file /trade_stats handler — portfolio summary with P&L breakdown.
|
* @file /trade_stats handler — portfolio summary with P&L breakdown.
|
||||||
|
* Fetches live stock prices for each held asset.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatCrypto, formatCurrency, formatPnL, formatStock, formatVND } from "./format.js";
|
import { formatPnL, formatStock, formatVND } from "./format.js";
|
||||||
import { getPortfolio } from "./portfolio.js";
|
import { getPortfolio } from "./portfolio.js";
|
||||||
import { getPrices } from "./prices.js";
|
import { getStockPrice } from "./prices.js";
|
||||||
|
|
||||||
/** /trade_stats — show full portfolio valued in VND with P&L */
|
/** /trade_stats — show full portfolio valued in VND with P&L */
|
||||||
export async function handleStats(ctx, db) {
|
export async function handleStats(ctx, db) {
|
||||||
const p = await getPortfolio(db, ctx.from?.id);
|
const p = await getPortfolio(db, ctx.from?.id);
|
||||||
let prices;
|
|
||||||
try {
|
|
||||||
prices = await getPrices(db);
|
|
||||||
} catch {
|
|
||||||
return ctx.reply("Could not fetch prices. Try again later.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ["📊 Portfolio Summary\n"];
|
const lines = ["📊 Portfolio Summary\n"];
|
||||||
let totalValue = 0;
|
let totalValue = 0;
|
||||||
|
|
||||||
// currencies
|
// VND balance
|
||||||
const currLines = [];
|
const vnd = p.currency.VND || 0;
|
||||||
for (const [cur, bal] of Object.entries(p.currency)) {
|
if (vnd > 0) {
|
||||||
if (bal === 0) continue;
|
totalValue += vnd;
|
||||||
const rate = cur === "VND" ? 1 : (prices.forex?.[cur]?.mid ?? 0);
|
lines.push(`VND: ${formatVND(vnd)}`);
|
||||||
const vndVal = bal * rate;
|
|
||||||
totalValue += vndVal;
|
|
||||||
currLines.push(
|
|
||||||
cur === "VND"
|
|
||||||
? ` VND: ${formatVND(bal)}`
|
|
||||||
: ` ${cur}: ${formatCurrency(bal, cur)} (~${formatVND(vndVal)})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (currLines.length) lines.push("Currency:", ...currLines);
|
|
||||||
|
|
||||||
// asset categories
|
// stock assets
|
||||||
for (const [catName, catLabel] of [
|
const assetEntries = Object.entries(p.assets);
|
||||||
["stock", "Stocks"],
|
if (assetEntries.length > 0) {
|
||||||
["crypto", "Crypto"],
|
lines.push("\nStocks:");
|
||||||
["others", "Others"],
|
for (const [sym, qty] of assetEntries) {
|
||||||
]) {
|
|
||||||
const catLines = [];
|
|
||||||
for (const [sym, qty] of Object.entries(p[catName])) {
|
|
||||||
if (qty === 0) continue;
|
if (qty === 0) continue;
|
||||||
const price = prices[catName]?.[sym];
|
let price;
|
||||||
|
try {
|
||||||
|
price = await getStockPrice(sym);
|
||||||
|
} catch {
|
||||||
|
price = null;
|
||||||
|
}
|
||||||
if (price == null) {
|
if (price == null) {
|
||||||
catLines.push(` ${sym}: ${qty} (no price)`);
|
lines.push(` ${sym} x${formatStock(qty)} (no price)`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const val = qty * price;
|
const val = qty * price;
|
||||||
totalValue += val;
|
totalValue += val;
|
||||||
const fmtQty = catName === "stock" ? formatStock(qty) : formatCrypto(qty);
|
lines.push(` ${sym} x${formatStock(qty)} @ ${formatVND(price)} = ${formatVND(val)}`);
|
||||||
catLines.push(` ${sym} x${fmtQty} @ ${formatVND(price)} = ${formatVND(val)}`);
|
|
||||||
}
|
}
|
||||||
if (catLines.length) lines.push(`\n${catLabel}:`, ...catLines);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`\nTotal value: ${formatVND(totalValue)}`);
|
lines.push(`\nTotal value: ${formatVND(totalValue)}`);
|
||||||
|
|||||||
@@ -1,53 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* @file Symbol registry — hardcoded list of tradable assets + fiat currencies.
|
* @file Symbol resolution — dynamically resolves stock tickers via TCBS API.
|
||||||
* Adding a new asset = one line here, no logic changes elsewhere.
|
* Resolved symbols are cached in KV permanently to avoid repeated lookups.
|
||||||
|
* Currently only supports VN stocks. Crypto, gold, forex coming later.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @typedef {{ category: "crypto"|"stock"|"others", apiId: string, label: string }} SymbolEntry */
|
const COMING_SOON = "Crypto, gold & currency exchange coming soon!";
|
||||||
|
|
||||||
/** @type {Readonly<Record<string, SymbolEntry>>} */
|
|
||||||
export const SYMBOLS = Object.freeze({
|
|
||||||
// crypto — CoinGecko IDs
|
|
||||||
BTC: { category: "crypto", apiId: "bitcoin", label: "Bitcoin" },
|
|
||||||
ETH: { category: "crypto", apiId: "ethereum", label: "Ethereum" },
|
|
||||||
SOL: { category: "crypto", apiId: "solana", label: "Solana" },
|
|
||||||
// Vietnam stocks — TCBS tickers
|
|
||||||
TCB: { category: "stock", apiId: "TCB", label: "Techcombank" },
|
|
||||||
VPB: { category: "stock", apiId: "VPB", label: "VPBank" },
|
|
||||||
FPT: { category: "stock", apiId: "FPT", label: "FPT Corp" },
|
|
||||||
VNM: { category: "stock", apiId: "VNM", label: "Vinamilk" },
|
|
||||||
HPG: { category: "stock", apiId: "HPG", label: "Hoa Phat" },
|
|
||||||
// others
|
|
||||||
GOLD: { category: "others", apiId: "pax-gold", label: "Gold (troy oz)" },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Supported fiat currencies */
|
|
||||||
export const CURRENCIES = Object.freeze(new Set(["VND", "USD"]));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Case-insensitive symbol lookup.
|
* @typedef {Object} ResolvedSymbol
|
||||||
* @param {string} name
|
* @property {string} symbol — uppercase ticker
|
||||||
* @returns {SymbolEntry & { symbol: string } | undefined}
|
* @property {string} category — "stock" (only supported category for now)
|
||||||
|
* @property {string} label — company name
|
||||||
*/
|
*/
|
||||||
export function getSymbol(name) {
|
|
||||||
if (!name) return undefined;
|
/**
|
||||||
const key = name.toUpperCase();
|
* Resolve a ticker to a symbol entry. Checks KV cache first, then queries TCBS.
|
||||||
const entry = SYMBOLS[key];
|
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||||
return entry ? { ...entry, symbol: key } : undefined;
|
* @param {string} ticker — user input, case-insensitive
|
||||||
|
* @returns {Promise<ResolvedSymbol|null>} null if not found on TCBS
|
||||||
|
*/
|
||||||
|
export async function resolveSymbol(db, ticker) {
|
||||||
|
if (!ticker) return null;
|
||||||
|
const symbol = ticker.toUpperCase();
|
||||||
|
const cacheKey = `sym:${symbol}`;
|
||||||
|
|
||||||
|
// check KV cache
|
||||||
|
const cached = await db.getJSON(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// query TCBS to verify this is a real VN stock
|
||||||
|
const to = Math.floor(Date.now() / 1000);
|
||||||
|
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${symbol}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
const close = json?.data?.[0]?.close;
|
||||||
|
if (close == null) return null;
|
||||||
|
|
||||||
|
const entry = { symbol, category: "stock", label: symbol };
|
||||||
|
// cache permanently — stock tickers don't change
|
||||||
|
await db.putJSON(cacheKey, entry);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formatted list of all supported symbols grouped by category.
|
* Error message for unsupported asset types.
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function listSymbols() {
|
export function comingSoonMessage() {
|
||||||
const groups = { crypto: [], stock: [], others: [] };
|
return COMING_SOON;
|
||||||
for (const [sym, entry] of Object.entries(SYMBOLS)) {
|
|
||||||
groups[entry.category].push(`${sym} — ${entry.label}`);
|
|
||||||
}
|
|
||||||
const lines = [];
|
|
||||||
if (groups.crypto.length) lines.push("Crypto:", ...groups.crypto.map((s) => ` ${s}`));
|
|
||||||
if (groups.stock.length) lines.push("Stocks:", ...groups.stock.map((s) => ` ${s}`));
|
|
||||||
if (groups.others.length) lines.push("Others:", ...groups.others.map((s) => ` ${s}`));
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { savePortfolio } from "../../../src/modules/trading/portfolio.js";
|
|||||||
import { handleStats } from "../../../src/modules/trading/stats-handler.js";
|
import { handleStats } from "../../../src/modules/trading/stats-handler.js";
|
||||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||||
|
|
||||||
/** Build a fake grammY context with .match, .from, and .reply spy */
|
|
||||||
function makeCtx(match = "", userId = 42) {
|
function makeCtx(match = "", userId = 42) {
|
||||||
const replies = [];
|
const replies = [];
|
||||||
return {
|
return {
|
||||||
@@ -21,27 +20,13 @@ function makeCtx(match = "", userId = 42) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stub global.fetch to return canned price data */
|
/** Stub fetch — TCBS returns stock data, BIDV returns forex */
|
||||||
function stubFetch() {
|
function stubFetch() {
|
||||||
global.fetch = vi.fn((url) => {
|
global.fetch = vi.fn((url) => {
|
||||||
if (url.includes("coingecko")) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
bitcoin: { vnd: 1500000000 },
|
|
||||||
ethereum: { vnd: 50000000 },
|
|
||||||
solana: { vnd: 3000000 },
|
|
||||||
"pax-gold": { vnd: 72000000 },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes("tcbs")) {
|
if (url.includes("tcbs")) {
|
||||||
const ticker = url.match(/ticker=(\w+)/)?.[1];
|
|
||||||
const prices = { TCB: 25, VPB: 18, FPT: 120, VNM: 70, HPG: 28 };
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }),
|
json: () => Promise.resolve({ data: [{ close: 25 }] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url.includes("bidv")) {
|
if (url.includes("bidv")) {
|
||||||
@@ -49,10 +34,7 @@ function stubFetch() {
|
|||||||
ok: true,
|
ok: true,
|
||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
data: [
|
data: [{ currency: "USD", muaCk: "25,200", ban: "25,600" }],
|
||||||
{ currency: "USD", muaCk: "25,200", ban: "25,600" },
|
|
||||||
{ currency: "EUR", muaCk: "28,000", ban: "28,500" },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,30 +85,34 @@ describe("trading/handlers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleBuy", () => {
|
describe("handleBuy", () => {
|
||||||
it("buys crypto with sufficient VND", async () => {
|
it("buys stock with sufficient VND", async () => {
|
||||||
// seed VND balance
|
|
||||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.VND = 20000000000;
|
p.currency.VND = 5000000;
|
||||||
p.totalvnd = 20000000000;
|
p.totalvnd = 5000000;
|
||||||
await savePortfolio(db, 42, p);
|
await savePortfolio(db, 42, p);
|
||||||
|
|
||||||
const ctx = makeCtx("0.01 BTC");
|
const ctx = makeCtx("10 TCB");
|
||||||
await handleBuy(ctx, db);
|
await handleBuy(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Bought");
|
expect(ctx.replies[0]).toContain("Bought");
|
||||||
expect(ctx.replies[0]).toContain("BTC");
|
expect(ctx.replies[0]).toContain("TCB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects buy with insufficient VND", async () => {
|
it("rejects buy with insufficient VND", async () => {
|
||||||
const ctx = makeCtx("1 BTC");
|
const ctx = makeCtx("10 TCB");
|
||||||
await handleBuy(ctx, db);
|
await handleBuy(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Insufficient VND");
|
expect(ctx.replies[0]).toContain("Insufficient VND");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown symbol", async () => {
|
it("rejects unknown ticker", async () => {
|
||||||
const ctx = makeCtx("1 NOPE");
|
// stub TCBS to return empty data for unknown ticker
|
||||||
|
global.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [] }) }),
|
||||||
|
);
|
||||||
|
const ctx = makeCtx("10 NOPE");
|
||||||
await handleBuy(ctx, db);
|
await handleBuy(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Unknown symbol");
|
expect(ctx.replies[0]).toContain("Unknown stock ticker");
|
||||||
|
expect(ctx.replies[0]).toContain("coming soon");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects fractional stock quantity", async () => {
|
it("rejects fractional stock quantity", async () => {
|
||||||
@@ -143,70 +129,30 @@ describe("trading/handlers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleSell", () => {
|
describe("handleSell", () => {
|
||||||
it("sells crypto holding", async () => {
|
it("sells stock holding", async () => {
|
||||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.crypto.BTC = 0.5;
|
p.assets.TCB = 50;
|
||||||
await savePortfolio(db, 42, p);
|
await savePortfolio(db, 42, p);
|
||||||
|
|
||||||
const ctx = makeCtx("0.1 BTC");
|
const ctx = makeCtx("10 TCB");
|
||||||
await handleSell(ctx, db);
|
await handleSell(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Sold");
|
expect(ctx.replies[0]).toContain("Sold");
|
||||||
expect(ctx.replies[0]).toContain("BTC");
|
expect(ctx.replies[0]).toContain("TCB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects sell with insufficient holdings", async () => {
|
it("rejects sell with insufficient holdings", async () => {
|
||||||
const ctx = makeCtx("1 BTC");
|
const ctx = makeCtx("10 TCB");
|
||||||
await handleSell(ctx, db);
|
await handleSell(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Insufficient BTC");
|
expect(ctx.replies[0]).toContain("Insufficient TCB");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleConvert", () => {
|
describe("handleConvert", () => {
|
||||||
it("converts USD to VND at buy rate (bank buys USD at lower price)", async () => {
|
it("shows coming soon message", async () => {
|
||||||
const { emptyPortfolio, getPortfolio } = await import(
|
|
||||||
"../../../src/modules/trading/portfolio.js"
|
|
||||||
);
|
|
||||||
const p = emptyPortfolio();
|
|
||||||
p.currency.USD = 100;
|
|
||||||
await savePortfolio(db, 42, p);
|
|
||||||
|
|
||||||
const ctx = makeCtx("50 USD VND");
|
|
||||||
await handleConvert(ctx, db);
|
|
||||||
expect(ctx.replies[0]).toContain("Converted");
|
|
||||||
// buy rate = 25,200 → 50 * 25200 = 1,260,000 VND
|
|
||||||
const loaded = await getPortfolio(db, 42);
|
|
||||||
expect(loaded.currency.VND).toBe(50 * 25200);
|
|
||||||
expect(ctx.replies[0]).toContain("buy:");
|
|
||||||
expect(ctx.replies[0]).toContain("sell:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts VND to USD at sell rate (bank sells USD at higher price)", async () => {
|
|
||||||
const { emptyPortfolio, getPortfolio } = await import(
|
|
||||||
"../../../src/modules/trading/portfolio.js"
|
|
||||||
);
|
|
||||||
const p = emptyPortfolio();
|
|
||||||
p.currency.VND = 30000000;
|
|
||||||
await savePortfolio(db, 42, p);
|
|
||||||
|
|
||||||
const ctx = makeCtx("1000000 VND USD");
|
|
||||||
await handleConvert(ctx, db);
|
|
||||||
expect(ctx.replies[0]).toContain("Converted");
|
|
||||||
// sell rate = 25,600 → 1M / 25600 ≈ 39.0625 USD
|
|
||||||
const loaded = await getPortfolio(db, 42);
|
|
||||||
expect(loaded.currency.USD).toBeCloseTo(1000000 / 25600, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects same currency conversion", async () => {
|
|
||||||
const ctx = makeCtx("100 VND VND");
|
|
||||||
await handleConvert(ctx, db);
|
|
||||||
expect(ctx.replies[0]).toContain("same currency");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects insufficient balance", async () => {
|
|
||||||
const ctx = makeCtx("100 USD VND");
|
const ctx = makeCtx("100 USD VND");
|
||||||
await handleConvert(ctx, db);
|
await handleConvert(ctx);
|
||||||
expect(ctx.replies[0]).toContain("Insufficient USD");
|
expect(ctx.replies[0]).toContain("coming soon");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,17 +164,17 @@ describe("trading/handlers", () => {
|
|||||||
expect(ctx.replies[0]).toContain("Total value:");
|
expect(ctx.replies[0]).toContain("Total value:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows portfolio with assets", async () => {
|
it("shows portfolio with stock assets", async () => {
|
||||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.VND = 5000000;
|
p.currency.VND = 5000000;
|
||||||
p.crypto.BTC = 0.01;
|
p.assets.TCB = 10;
|
||||||
p.totalvnd = 20000000;
|
p.totalvnd = 10000000;
|
||||||
await savePortfolio(db, 42, p);
|
await savePortfolio(db, 42, p);
|
||||||
|
|
||||||
const ctx = makeCtx("");
|
const ctx = makeCtx("");
|
||||||
await handleStats(ctx, db);
|
await handleStats(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("BTC");
|
expect(ctx.replies[0]).toContain("TCB");
|
||||||
expect(ctx.replies[0]).toContain("P&L:");
|
expect(ctx.replies[0]).toContain("P&L:");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||||
|
|
||||||
describe("trading/portfolio", () => {
|
describe("trading/portfolio", () => {
|
||||||
/** @type {import("../../../src/db/kv-store-interface.js").KVStore} */
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -22,10 +21,8 @@ describe("trading/portfolio", () => {
|
|||||||
describe("emptyPortfolio", () => {
|
describe("emptyPortfolio", () => {
|
||||||
it("returns correct shape", () => {
|
it("returns correct shape", () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
expect(p.currency).toEqual({ VND: 0, USD: 0 });
|
expect(p.currency).toEqual({ VND: 0 });
|
||||||
expect(p.stock).toEqual({});
|
expect(p.assets).toEqual({});
|
||||||
expect(p.crypto).toEqual({});
|
|
||||||
expect(p.others).toEqual({});
|
|
||||||
expect(p.totalvnd).toBe(0);
|
expect(p.totalvnd).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -35,24 +32,37 @@ describe("trading/portfolio", () => {
|
|||||||
const p = await getPortfolio(db, 123);
|
const p = await getPortfolio(db, 123);
|
||||||
expect(p.currency.VND).toBe(0);
|
expect(p.currency.VND).toBe(0);
|
||||||
expect(p.totalvnd).toBe(0);
|
expect(p.totalvnd).toBe(0);
|
||||||
|
expect(p.assets).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips saved data", async () => {
|
it("round-trips saved data", async () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.VND = 5000000;
|
p.currency.VND = 5000000;
|
||||||
|
p.assets.TCB = 10;
|
||||||
p.totalvnd = 5000000;
|
p.totalvnd = 5000000;
|
||||||
await savePortfolio(db, 123, p);
|
await savePortfolio(db, 123, p);
|
||||||
const loaded = await getPortfolio(db, 123);
|
const loaded = await getPortfolio(db, 123);
|
||||||
expect(loaded.currency.VND).toBe(5000000);
|
expect(loaded.currency.VND).toBe(5000000);
|
||||||
|
expect(loaded.assets.TCB).toBe(10);
|
||||||
expect(loaded.totalvnd).toBe(5000000);
|
expect(loaded.totalvnd).toBe(5000000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fills missing category keys on load (migration-safe)", async () => {
|
it("migrates old 4-category format to flat assets", async () => {
|
||||||
// simulate old data missing 'others' key
|
// simulate old format with stock/crypto/others
|
||||||
await db.putJSON("user:123", { currency: { VND: 100 }, stock: {}, crypto: {} });
|
await db.putJSON("user:123", {
|
||||||
|
currency: { VND: 100 },
|
||||||
|
stock: { TCB: 10 },
|
||||||
|
crypto: { BTC: 0.5 },
|
||||||
|
others: { GOLD: 1 },
|
||||||
|
totalvnd: 100,
|
||||||
|
});
|
||||||
const p = await getPortfolio(db, 123);
|
const p = await getPortfolio(db, 123);
|
||||||
expect(p.others).toEqual({});
|
expect(p.assets.TCB).toBe(10);
|
||||||
expect(p.totalvnd).toBe(0);
|
expect(p.assets.BTC).toBe(0.5);
|
||||||
|
expect(p.assets.GOLD).toBe(1);
|
||||||
|
// old category keys should not exist
|
||||||
|
expect(p.stock).toBeUndefined();
|
||||||
|
expect(p.crypto).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,63 +87,51 @@ describe("trading/portfolio", () => {
|
|||||||
const result = deductCurrency(p, "VND", 5000);
|
const result = deductCurrency(p, "VND", 5000);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.balance).toBe(1000);
|
expect(result.balance).toBe(1000);
|
||||||
expect(p.currency.VND).toBe(1000); // unchanged
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("addAsset / deductAsset", () => {
|
describe("addAsset / deductAsset", () => {
|
||||||
it("adds crypto asset", () => {
|
it("adds asset to flat map", () => {
|
||||||
const p = emptyPortfolio();
|
|
||||||
addAsset(p, "BTC", 0.5);
|
|
||||||
expect(p.crypto.BTC).toBe(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds stock asset", () => {
|
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
addAsset(p, "TCB", 10);
|
addAsset(p, "TCB", 10);
|
||||||
expect(p.stock.TCB).toBe(10);
|
expect(p.assets.TCB).toBe(10);
|
||||||
});
|
|
||||||
|
|
||||||
it("adds others asset", () => {
|
|
||||||
const p = emptyPortfolio();
|
|
||||||
addAsset(p, "GOLD", 1);
|
|
||||||
expect(p.others.GOLD).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates on repeated add", () => {
|
it("accumulates on repeated add", () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
addAsset(p, "BTC", 0.1);
|
addAsset(p, "TCB", 10);
|
||||||
addAsset(p, "BTC", 0.2);
|
addAsset(p, "TCB", 5);
|
||||||
expect(p.crypto.BTC).toBeCloseTo(0.3);
|
expect(p.assets.TCB).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deducts asset when sufficient", () => {
|
it("deducts asset when sufficient", () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.crypto.BTC = 1.0;
|
p.assets.TCB = 10;
|
||||||
const result = deductAsset(p, "BTC", 0.3);
|
const result = deductAsset(p, "TCB", 3);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(p.crypto.BTC).toBeCloseTo(0.7);
|
expect(p.assets.TCB).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes key when deducted to zero", () => {
|
it("removes key when deducted to zero", () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.crypto.BTC = 0.5;
|
p.assets.TCB = 5;
|
||||||
deductAsset(p, "BTC", 0.5);
|
deductAsset(p, "TCB", 5);
|
||||||
expect(p.crypto.BTC).toBeUndefined();
|
expect(p.assets.TCB).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects deduction when insufficient", () => {
|
it("rejects deduction when insufficient", () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.crypto.BTC = 0.1;
|
p.assets.TCB = 3;
|
||||||
const result = deductAsset(p, "BTC", 0.5);
|
const result = deductAsset(p, "TCB", 10);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.held).toBe(0.1);
|
expect(result.held).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects deduction for unknown symbol", () => {
|
it("rejects deduction for unowned symbol", () => {
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
const result = deductAsset(p, "NOPE", 1);
|
const result = deductAsset(p, "NOPE", 1);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.held).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,46 +1,59 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import { createStore } from "../../../src/db/create-store.js";
|
||||||
CURRENCIES,
|
import { comingSoonMessage, resolveSymbol } from "../../../src/modules/trading/symbols.js";
|
||||||
SYMBOLS,
|
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||||
getSymbol,
|
|
||||||
listSymbols,
|
/** Stub global.fetch to return TCBS-like response */
|
||||||
} from "../../../src/modules/trading/symbols.js";
|
function stubFetch(hasData = true) {
|
||||||
|
global.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(hasData ? { data: [{ close: 25 }] } : { data: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("trading/symbols", () => {
|
describe("trading/symbols", () => {
|
||||||
it("SYMBOLS has 9 entries across 3 categories", () => {
|
let db;
|
||||||
expect(Object.keys(SYMBOLS)).toHaveLength(9);
|
|
||||||
const cats = new Set(Object.values(SYMBOLS).map((s) => s.category));
|
beforeEach(() => {
|
||||||
expect(cats).toEqual(new Set(["crypto", "stock", "others"]));
|
db = createStore("trading", { KV: makeFakeKv() });
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getSymbol is case-insensitive", () => {
|
it("resolves a valid VN stock ticker via TCBS", async () => {
|
||||||
const btc = getSymbol("btc");
|
stubFetch();
|
||||||
expect(btc).toBeDefined();
|
const result = await resolveSymbol(db, "TCB");
|
||||||
expect(btc.symbol).toBe("BTC");
|
expect(result).toEqual({ symbol: "TCB", category: "stock", label: "TCB" });
|
||||||
expect(btc.category).toBe("crypto");
|
expect(global.fetch).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
expect(getSymbol("Tcb").symbol).toBe("TCB");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getSymbol returns undefined for unknown symbols", () => {
|
it("caches resolved symbol in KV", async () => {
|
||||||
expect(getSymbol("NOPE")).toBeUndefined();
|
stubFetch();
|
||||||
expect(getSymbol("")).toBeUndefined();
|
await resolveSymbol(db, "TCB");
|
||||||
expect(getSymbol(null)).toBeUndefined();
|
// second call should use cache, not fetch
|
||||||
|
await resolveSymbol(db, "TCB");
|
||||||
|
expect(global.fetch).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CURRENCIES contains VND and USD", () => {
|
it("is case-insensitive", async () => {
|
||||||
expect(CURRENCIES.has("VND")).toBe(true);
|
stubFetch();
|
||||||
expect(CURRENCIES.has("USD")).toBe(true);
|
const result = await resolveSymbol(db, "tcb");
|
||||||
expect(CURRENCIES.has("EUR")).toBe(false);
|
expect(result.symbol).toBe("TCB");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listSymbols returns grouped output", () => {
|
it("returns null for invalid ticker", async () => {
|
||||||
const out = listSymbols();
|
stubFetch(false);
|
||||||
expect(out).toContain("Crypto:");
|
const result = await resolveSymbol(db, "NOPE");
|
||||||
expect(out).toContain("BTC — Bitcoin");
|
expect(result).toBeNull();
|
||||||
expect(out).toContain("Stocks:");
|
});
|
||||||
expect(out).toContain("TCB — Techcombank");
|
|
||||||
expect(out).toContain("Others:");
|
it("returns null for empty input", async () => {
|
||||||
expect(out).toContain("GOLD — Gold");
|
const result = await resolveSymbol(db, "");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comingSoonMessage returns string", () => {
|
||||||
|
expect(comingSoonMessage()).toContain("coming soon");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user