mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user