mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +00:00
feat: simplify topup to VND-only, add bid/ask spread to convert
Topup now only accepts VND — users must convert to get other currencies. Convert uses a 0.5% bid/ask spread: buying USD costs more VND (ask), selling USD back gives less VND (bid). Simulates real forex behavior.
This commit is contained in:
@@ -6,10 +6,10 @@ Paper-trading system where each Telegram user manages a virtual portfolio.
|
|||||||
|
|
||||||
| Command | Action |
|
| Command | Action |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| `/trade_topup <amount> [currency]` | Add fiat (VND default). 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 fiat currencies (VND, USD). |
|
| `/trade_convert <amount> <from> <to>` | Convert between currencies with bid/ask spread (0.5%). |
|
||||||
| `/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
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
import { getForexRate, getPrice } from "./prices.js";
|
import { getForexRate, 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;
|
||||||
}
|
}
|
||||||
@@ -27,31 +30,20 @@ function usageReply(ctx, usage) {
|
|||||||
return ctx.reply(`Usage: ${usage}`);
|
return ctx.reply(`Usage: ${usage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /trade_topup <amount> [currency=VND] */
|
/** /trade_topup <amount> — add VND to account */
|
||||||
export async function handleTopup(ctx, db) {
|
export async function handleTopup(ctx, db) {
|
||||||
const args = parseArgs(ctx);
|
const args = parseArgs(ctx);
|
||||||
if (args.length < 1) return usageReply(ctx, "/trade_topup <amount> [VND|USD]");
|
if (args.length < 1)
|
||||||
|
return usageReply(ctx, "/trade_topup <amount>\nExample: /trade_topup 5000000");
|
||||||
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 currency = (args[1] || "VND").toUpperCase();
|
|
||||||
if (!CURRENCIES.has(currency))
|
|
||||||
return ctx.reply(`Unsupported currency. Use: ${[...CURRENCIES].join(", ")}`);
|
|
||||||
|
|
||||||
const p = await getPortfolio(db, uid(ctx));
|
const p = await getPortfolio(db, uid(ctx));
|
||||||
addCurrency(p, currency, amount);
|
addCurrency(p, "VND", amount);
|
||||||
if (currency === "VND") {
|
p.totalvnd += amount;
|
||||||
p.totalvnd += amount;
|
|
||||||
} else {
|
|
||||||
const rate = await getForexRate(db, currency);
|
|
||||||
if (rate == null) return ctx.reply("Could not fetch forex rate. Try again later.");
|
|
||||||
p.totalvnd += amount * rate;
|
|
||||||
}
|
|
||||||
await savePortfolio(db, uid(ctx), p);
|
await savePortfolio(db, uid(ctx), p);
|
||||||
const bal = p.currency[currency];
|
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
||||||
await ctx.reply(
|
|
||||||
`Topped up ${formatCurrency(amount, currency)}.\nBalance: ${formatCurrency(bal, currency)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /trade_buy <amount> <symbol> */
|
/** /trade_buy <amount> <symbol> */
|
||||||
@@ -126,7 +118,7 @@ export async function handleSell(ctx, db) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /trade_convert <amount> <from> <to> */
|
/** /trade_convert <amount> <from> <to> — with bid/ask spread */
|
||||||
export async function handleConvert(ctx, db) {
|
export async function handleConvert(ctx, db) {
|
||||||
const args = parseArgs(ctx);
|
const args = parseArgs(ctx);
|
||||||
if (args.length < 3)
|
if (args.length < 3)
|
||||||
@@ -143,22 +135,38 @@ 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 fromRate;
|
let midRate;
|
||||||
let toRate;
|
|
||||||
try {
|
try {
|
||||||
[fromRate, toRate] = await Promise.all([getForexRate(db, from), getForexRate(db, to)]);
|
midRate = await getForexRate(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 (fromRate == null || toRate == null)
|
if (midRate == null) return ctx.reply("Forex rate unavailable. Try again later.");
|
||||||
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)}`);
|
||||||
const converted = (amount * fromRate) / toRate;
|
|
||||||
|
let converted;
|
||||||
|
let rateUsed;
|
||||||
|
if (from === "VND" && to === "USD") {
|
||||||
|
// buying USD with VND — pay ask price
|
||||||
|
converted = amount / buyRate;
|
||||||
|
rateUsed = buyRate;
|
||||||
|
} else {
|
||||||
|
// selling USD for VND — receive bid price
|
||||||
|
converted = amount * sellRate;
|
||||||
|
rateUsed = sellRate;
|
||||||
|
}
|
||||||
|
|
||||||
addCurrency(p, to, converted);
|
addCurrency(p, to, converted);
|
||||||
await savePortfolio(db, uid(ctx), p);
|
await savePortfolio(db, uid(ctx), p);
|
||||||
await ctx.reply(`Converted ${formatCurrency(amount, from)} → ${formatCurrency(converted, to)}`);
|
await ctx.reply(
|
||||||
|
`Converted ${formatCurrency(amount, from)} → ${formatCurrency(converted, to)}\nRate: ${formatVND(rateUsed)}/USD (spread: ${(FOREX_SPREAD * 100).toFixed(1)}%)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const tradingModule = {
|
|||||||
{
|
{
|
||||||
name: "trade_topup",
|
name: "trade_topup",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
description: "Top up fiat to your trading account",
|
description: "Top up VND to your trading account",
|
||||||
handler: (ctx) => handleTopup(ctx, db),
|
handler: (ctx) => handleTopup(ctx, db),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ const tradingModule = {
|
|||||||
{
|
{
|
||||||
name: "trade_convert",
|
name: "trade_convert",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
description: "Convert between fiat currencies",
|
description: "Convert between currencies (bid/ask spread)",
|
||||||
handler: (ctx) => handleConvert(ctx, db),
|
handler: (ctx) => handleConvert(ctx, db),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,22 +68,19 @@ describe("trading/handlers", () => {
|
|||||||
|
|
||||||
describe("handleTopup", () => {
|
describe("handleTopup", () => {
|
||||||
it("tops up VND", async () => {
|
it("tops up VND", async () => {
|
||||||
const ctx = makeCtx("5000000 VND");
|
const ctx = makeCtx("5000000");
|
||||||
await handleTopup(ctx, db);
|
await handleTopup(ctx, db);
|
||||||
expect(ctx.reply).toHaveBeenCalledOnce();
|
expect(ctx.reply).toHaveBeenCalledOnce();
|
||||||
expect(ctx.replies[0]).toContain("5.000.000 VND");
|
expect(ctx.replies[0]).toContain("5.000.000 VND");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tops up USD and tracks VND equivalent in totalvnd", async () => {
|
it("tracks totalvnd", async () => {
|
||||||
const ctx = makeCtx("100 USD");
|
|
||||||
await handleTopup(ctx, db);
|
|
||||||
expect(ctx.replies[0]).toContain("$100.00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to VND when currency omitted", async () => {
|
|
||||||
const ctx = makeCtx("1000000");
|
const ctx = makeCtx("1000000");
|
||||||
await handleTopup(ctx, db);
|
await handleTopup(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("VND");
|
const { getPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
||||||
|
const p = await getPortfolio(db, 42);
|
||||||
|
expect(p.totalvnd).toBe(1000000);
|
||||||
|
expect(p.currency.VND).toBe(1000000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects missing args", async () => {
|
it("rejects missing args", async () => {
|
||||||
@@ -93,16 +90,10 @@ describe("trading/handlers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative amount", async () => {
|
it("rejects negative amount", async () => {
|
||||||
const ctx = makeCtx("-100 VND");
|
const ctx = makeCtx("-100");
|
||||||
await handleTopup(ctx, db);
|
await handleTopup(ctx, db);
|
||||||
expect(ctx.replies[0]).toContain("positive");
|
expect(ctx.replies[0]).toContain("positive");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsupported currency", async () => {
|
|
||||||
const ctx = makeCtx("100 EUR");
|
|
||||||
await handleTopup(ctx, db);
|
|
||||||
expect(ctx.replies[0]).toContain("Unsupported");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleBuy", () => {
|
describe("handleBuy", () => {
|
||||||
@@ -166,8 +157,10 @@ describe("trading/handlers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleConvert", () => {
|
describe("handleConvert", () => {
|
||||||
it("converts USD to VND", async () => {
|
it("converts USD to VND at bid rate (less than mid)", async () => {
|
||||||
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
|
const { emptyPortfolio, getPortfolio } = await import(
|
||||||
|
"../../../src/modules/trading/portfolio.js"
|
||||||
|
);
|
||||||
const p = emptyPortfolio();
|
const p = emptyPortfolio();
|
||||||
p.currency.USD = 100;
|
p.currency.USD = 100;
|
||||||
await savePortfolio(db, 42, p);
|
await savePortfolio(db, 42, p);
|
||||||
@@ -175,7 +168,28 @@ 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");
|
||||||
expect(ctx.replies[0]).toContain("VND");
|
// bid rate = 25400 * 0.995 = 25273 VND/USD → 50 * 25273 = 1,263,650
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts VND to USD at ask rate (costs more VND)", 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");
|
||||||
|
// ask rate = 25400 * 1.005 = 25527 VND/USD → 1M / 25527 ≈ 39.17 USD
|
||||||
|
const loaded = await getPortfolio(db, 42);
|
||||||
|
expect(loaded.currency.USD).toBeLessThan(1000000 / 25400); // less USD than mid
|
||||||
|
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