mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +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_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 <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_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. |
|
| `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. |
|
||||||
|
|
||||||
## Supported Symbols
|
## Supported Symbols
|
||||||
@@ -62,7 +62,7 @@ KV namespace prefix: `trading:`
|
|||||||
"ts": 1713100000000,
|
"ts": 1713100000000,
|
||||||
"crypto": { "BTC": 1500000000, "ETH": 50000000, "SOL": 3000000 },
|
"crypto": { "BTC": 1500000000, "ETH": 50000000, "SOL": 3000000 },
|
||||||
"stock": { "TCB": 25000, "VPB": 18000, "FPT": 120000, "VNM": 70000, "HPG": 28000 },
|
"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 }
|
"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) |
|
| 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 |
|
| 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.
|
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,
|
getPortfolio,
|
||||||
savePortfolio,
|
savePortfolio,
|
||||||
} from "./portfolio.js";
|
} from "./portfolio.js";
|
||||||
import { getForexRate, getPrice } from "./prices.js";
|
import { getForexBidAsk, getPrice } from "./prices.js";
|
||||||
import { CURRENCIES, getSymbol, listSymbols } from "./symbols.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) {
|
function uid(ctx) {
|
||||||
return ctx.from?.id;
|
return ctx.from?.id;
|
||||||
}
|
}
|
||||||
@@ -135,38 +132,36 @@ export async function handleConvert(ctx, db) {
|
|||||||
return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`);
|
return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`);
|
||||||
if (from === to) return ctx.reply("Cannot convert to the same currency.");
|
if (from === to) return ctx.reply("Cannot convert to the same currency.");
|
||||||
|
|
||||||
let midRate;
|
let rates;
|
||||||
try {
|
try {
|
||||||
midRate = await getForexRate(db, "USD");
|
rates = await getForexBidAsk(db, "USD");
|
||||||
} catch {
|
} catch {
|
||||||
return ctx.reply("Could not fetch forex rate. Try again later.");
|
return ctx.reply("Could not fetch forex rate. Try again later.");
|
||||||
}
|
}
|
||||||
if (midRate == null) return ctx.reply("Forex rate unavailable. Try again later.");
|
if (!rates) 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
|
|
||||||
|
|
||||||
const p = await getPortfolio(db, uid(ctx));
|
const p = await getPortfolio(db, uid(ctx));
|
||||||
const result = deductCurrency(p, from, amount);
|
const result = deductCurrency(p, from, amount);
|
||||||
if (!result.ok)
|
if (!result.ok)
|
||||||
return ctx.reply(`Insufficient ${from}. Balance: ${formatCurrency(result.balance, from)}`);
|
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 converted;
|
||||||
let rateUsed;
|
let rateUsed;
|
||||||
if (from === "VND" && to === "USD") {
|
if (from === "VND" && to === "USD") {
|
||||||
// buying USD with VND — pay ask price
|
// you're buying USD from bank → bank sells at higher price
|
||||||
converted = amount / buyRate;
|
converted = amount / rates.sell;
|
||||||
rateUsed = buyRate;
|
rateUsed = rates.sell;
|
||||||
} else {
|
} else {
|
||||||
// selling USD for VND — receive bid price
|
// you're selling USD to bank → bank buys at lower price
|
||||||
converted = amount * sellRate;
|
converted = amount * rates.buy;
|
||||||
rateUsed = sellRate;
|
rateUsed = rates.buy;
|
||||||
}
|
}
|
||||||
|
|
||||||
addCurrency(p, to, converted);
|
addCurrency(p, to, converted);
|
||||||
await savePortfolio(db, uid(ctx), p);
|
await savePortfolio(db, uid(ctx), p);
|
||||||
|
const spread = (((rates.sell - rates.buy) / rates.buy) * 100).toFixed(2);
|
||||||
await ctx.reply(
|
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;
|
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() {
|
async function fetchForex() {
|
||||||
const res = await fetch("https://open.er-api.com/v6/latest/USD");
|
const res = await fetch("https://www.bidv.com.vn/ServicesBIDV/ExchangeDetailServlet");
|
||||||
if (!res.ok) throw new Error(`Forex API ${res.status}`);
|
if (!res.ok) throw new Error(`BIDV API ${res.status}`);
|
||||||
const data = await res.json();
|
const json = await res.json();
|
||||||
const vndRate = data?.rates?.VND;
|
const usd = json?.data?.find((r) => r.currency === "USD");
|
||||||
if (vndRate == null) throw new Error("Forex missing VND rate");
|
if (!usd) throw new Error("BIDV missing USD rate");
|
||||||
return { USD: vndRate };
|
// 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 {import("../../db/kv-store-interface.js").KVStore} db
|
||||||
* @param {string} currency — "VND" or "USD"
|
* @param {string} currency — "VND" or "USD"
|
||||||
* @returns {Promise<number>}
|
* @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 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 = [];
|
const currLines = [];
|
||||||
for (const [cur, bal] of Object.entries(p.currency)) {
|
for (const [cur, bal] of Object.entries(p.currency)) {
|
||||||
if (bal === 0) continue;
|
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;
|
const vndVal = bal * rate;
|
||||||
totalValue += vndVal;
|
totalValue += vndVal;
|
||||||
currLines.push(
|
currLines.push(
|
||||||
|
|||||||
@@ -44,10 +44,16 @@ function stubFetch() {
|
|||||||
json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }),
|
json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url.includes("er-api")) {
|
if (url.includes("bidv")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
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}`));
|
return Promise.reject(new Error(`unexpected fetch: ${url}`));
|
||||||
@@ -157,7 +163,7 @@ describe("trading/handlers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleConvert", () => {
|
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(
|
const { emptyPortfolio, getPortfolio } = await import(
|
||||||
"../../../src/modules/trading/portfolio.js"
|
"../../../src/modules/trading/portfolio.js"
|
||||||
);
|
);
|
||||||
@@ -168,14 +174,14 @@ describe("trading/handlers", () => {
|
|||||||
const ctx = makeCtx("50 USD VND");
|
const ctx = makeCtx("50 USD VND");
|
||||||
await handleConvert(ctx, db);
|
await handleConvert(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Converted");
|
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);
|
const loaded = await getPortfolio(db, 42);
|
||||||
expect(loaded.currency.VND).toBeLessThan(50 * 25400); // less than mid rate
|
expect(loaded.currency.VND).toBe(50 * 25200);
|
||||||
expect(loaded.currency.VND).toBeGreaterThan(0);
|
expect(ctx.replies[0]).toContain("buy:");
|
||||||
expect(ctx.replies[0]).toContain("spread");
|
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(
|
const { emptyPortfolio, getPortfolio } = await import(
|
||||||
"../../../src/modules/trading/portfolio.js"
|
"../../../src/modules/trading/portfolio.js"
|
||||||
);
|
);
|
||||||
@@ -186,10 +192,9 @@ describe("trading/handlers", () => {
|
|||||||
const ctx = makeCtx("1000000 VND USD");
|
const ctx = makeCtx("1000000 VND USD");
|
||||||
await handleConvert(ctx, db);
|
await handleConvert(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("Converted");
|
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);
|
const loaded = await getPortfolio(db, 42);
|
||||||
expect(loaded.currency.USD).toBeLessThan(1000000 / 25400); // less USD than mid
|
expect(loaded.currency.USD).toBeCloseTo(1000000 / 25600, 2);
|
||||||
expect(loaded.currency.USD).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects same currency conversion", async () => {
|
it("rejects same currency conversion", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user