diff --git a/src/modules/trading/README.md b/src/modules/trading/README.md index 4746eb4..5e1cf9f 100644 --- a/src/modules/trading/README.md +++ b/src/modules/trading/README.md @@ -9,7 +9,7 @@ Paper-trading system where each Telegram user manages a virtual portfolio. | `/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 with bid/ask spread (0.5%). | +| `/trade_convert ` | Convert between currencies at real BIDV bid/ask rates. | | `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. | ## Supported Symbols @@ -62,7 +62,7 @@ KV namespace prefix: `trading:` "ts": 1713100000000, "crypto": { "BTC": 1500000000, "ETH": 50000000, "SOL": 3000000 }, "stock": { "TCB": 25000, "VPB": 18000, "FPT": 120000, "VNM": 70000, "HPG": 28000 }, - "forex": { "USD": 25400 }, + "forex": { "USD": { "mid": 25400, "buy": 25200, "sell": 25600 } }, "others": { "GOLD": 72000000 } } ``` @@ -79,7 +79,7 @@ Three free APIs fetched in parallel, cached in KV for 60 seconds: |-----|---------|------|-----------| | 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 | -| open.er-api.com `/v6/latest/USD` | USD/VND forex rate | None | 1,500/month (free) | +| 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. diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js index 3933a41..ad650fe 100644 --- a/src/modules/trading/handlers.js +++ b/src/modules/trading/handlers.js @@ -12,12 +12,9 @@ import { getPortfolio, savePortfolio, } from "./portfolio.js"; -import { getForexRate, getPrice } from "./prices.js"; +import { getForexBidAsk, getPrice } from "./prices.js"; import { CURRENCIES, getSymbol, listSymbols } from "./symbols.js"; -/** Bid/ask spread for forex conversion (0.5% each side of mid rate) */ -const FOREX_SPREAD = 0.005; - function uid(ctx) { return ctx.from?.id; } @@ -135,38 +132,36 @@ export async function handleConvert(ctx, db) { return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`); if (from === to) return ctx.reply("Cannot convert to the same currency."); - let midRate; + let rates; try { - midRate = await getForexRate(db, "USD"); + rates = await getForexBidAsk(db, "USD"); } catch { return ctx.reply("Could not fetch forex rate. Try again later."); } - if (midRate == null) return ctx.reply("Forex rate unavailable. Try again later."); - - // Buying foreign currency costs more (ask), selling it back gives less (bid) - const buyRate = midRate * (1 + FOREX_SPREAD); // VND per USD when buying USD - const sellRate = midRate * (1 - FOREX_SPREAD); // VND per USD when selling USD + 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") { - // buying USD with VND — pay ask price - converted = amount / buyRate; - rateUsed = buyRate; + // you're buying USD from bank → bank sells at higher price + converted = amount / rates.sell; + rateUsed = rates.sell; } else { - // selling USD for VND — receive bid price - converted = amount * sellRate; - rateUsed = sellRate; + // 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 (spread: ${(FOREX_SPREAD * 100).toFixed(1)}%)`, + `Converted ${formatCurrency(amount, from)} → ${formatCurrency(converted, to)}\nRate: ${formatVND(rateUsed)}/USD (buy: ${formatVND(rates.buy)}, sell: ${formatVND(rates.sell)}, spread: ${spread}%)`, ); } diff --git a/src/modules/trading/prices.js b/src/modules/trading/prices.js index 8151012..10bf5cf 100644 --- a/src/modules/trading/prices.js +++ b/src/modules/trading/prices.js @@ -54,14 +54,21 @@ async function fetchStocks() { return stock; } -/** Forex rates via open.er-api.com — VND per 1 USD */ +/** Forex rates via BIDV public API — returns real buy/sell rates */ async function fetchForex() { - const res = await fetch("https://open.er-api.com/v6/latest/USD"); - if (!res.ok) throw new Error(`Forex API ${res.status}`); - const data = await res.json(); - const vndRate = data?.rates?.VND; - if (vndRate == null) throw new Error("Forex missing VND rate"); - return { USD: vndRate }; + const res = await fetch("https://www.bidv.com.vn/ServicesBIDV/ExchangeDetailServlet"); + if (!res.ok) throw new Error(`BIDV API ${res.status}`); + 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); + if (!Number.isFinite(buy) || !Number.isFinite(sell)) { + throw new Error("BIDV invalid USD rate"); + } + return { USD: { mid: (buy + sell) / 2, buy, sell } }; } /** @@ -119,13 +126,28 @@ export async function getPrice(db, symbol) { } /** - * VND equivalent of 1 unit of currency. + * 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" - * @returns {Promise} + * @returns {Promise} */ export async function getForexRate(db, currency) { if (currency === "VND") return 1; const prices = await getPrices(db); - return prices.forex?.[currency] ?? null; + return prices.forex?.[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]; + 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 cee00ea..506368c 100644 --- a/src/modules/trading/stats-handler.js +++ b/src/modules/trading/stats-handler.js @@ -23,7 +23,7 @@ export async function handleStats(ctx, db) { const currLines = []; for (const [cur, bal] of Object.entries(p.currency)) { if (bal === 0) continue; - const rate = cur === "VND" ? 1 : (prices.forex?.[cur] ?? 0); + const rate = cur === "VND" ? 1 : (prices.forex?.[cur]?.mid ?? 0); const vndVal = bal * rate; totalValue += vndVal; currLines.push( diff --git a/tests/modules/trading/handlers.test.js b/tests/modules/trading/handlers.test.js index 4769d1b..a6ad83f 100644 --- a/tests/modules/trading/handlers.test.js +++ b/tests/modules/trading/handlers.test.js @@ -44,10 +44,16 @@ function stubFetch() { json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }), }); } - if (url.includes("er-api")) { + if (url.includes("bidv")) { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ rates: { VND: 25400 } }), + json: () => + Promise.resolve({ + data: [ + { currency: "USD", muaCk: "25,200", ban: "25,600" }, + { currency: "EUR", muaCk: "28,000", ban: "28,500" }, + ], + }), }); } return Promise.reject(new Error(`unexpected fetch: ${url}`)); @@ -157,7 +163,7 @@ describe("trading/handlers", () => { }); describe("handleConvert", () => { - it("converts USD to VND at bid rate (less than mid)", async () => { + 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" ); @@ -168,14 +174,14 @@ describe("trading/handlers", () => { const ctx = makeCtx("50 USD VND"); await handleConvert(ctx, db); expect(ctx.replies[0]).toContain("Converted"); - // bid rate = 25400 * 0.995 = 25273 VND/USD → 50 * 25273 = 1,263,650 + // buy rate = 25,200 → 50 * 25200 = 1,260,000 VND const loaded = await getPortfolio(db, 42); - expect(loaded.currency.VND).toBeLessThan(50 * 25400); // less than mid rate - expect(loaded.currency.VND).toBeGreaterThan(0); - expect(ctx.replies[0]).toContain("spread"); + 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 ask rate (costs more VND)", async () => { + 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" ); @@ -186,10 +192,9 @@ describe("trading/handlers", () => { const ctx = makeCtx("1000000 VND USD"); await handleConvert(ctx, db); expect(ctx.replies[0]).toContain("Converted"); - // ask rate = 25400 * 1.005 = 25527 VND/USD → 1M / 25527 ≈ 39.17 USD + // sell rate = 25,600 → 1M / 25600 ≈ 39.0625 USD const loaded = await getPortfolio(db, 42); - expect(loaded.currency.USD).toBeLessThan(1000000 / 25400); // less USD than mid - expect(loaded.currency.USD).toBeGreaterThan(0); + expect(loaded.currency.USD).toBeCloseTo(1000000 / 25600, 2); }); it("rejects same currency conversion", async () => {