feat: use real BIDV bid/ask rates for forex conversion

Replace hardcoded 0.5% spread with live buy/sell rates from BIDV bank
API. Buying USD uses bank's sell rate (higher), selling USD uses bank's
buy rate (lower). Reply shows both rates and actual spread percentage.
This commit is contained in:
2026-04-14 16:53:07 +07:00
parent f3aaf16d6a
commit 86268341d1
5 changed files with 65 additions and 43 deletions

View File

@@ -9,7 +9,7 @@ Paper-trading system where each Telegram user manages a virtual portfolio.
| `/trade_topup <amount>` | Add VND to account. Tracks cumulative invested via `totalvnd`. |
| `/trade_buy <amount> <symbol>` | Buy at market price, deducting VND. Stocks must be integer quantities. |
| `/trade_sell <amount> <symbol>` | Sell holdings back to VND at market price. |
| `/trade_convert <amount> <from> <to>` | Convert between currencies with bid/ask spread (0.5%). |
| `/trade_convert <amount> <from> <to>` | 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.

View File

@@ -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}%)`,
);
}

View File

@@ -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<number>}
* @returns {Promise<number|null>}
*/
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 };
}

View File

@@ -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(

View File

@@ -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 () => {