diff --git a/src/modules/trading/README.md b/src/modules/trading/README.md index 50c92c3..4746eb4 100644 --- a/src/modules/trading/README.md +++ b/src/modules/trading/README.md @@ -6,10 +6,10 @@ Paper-trading system where each Telegram user manages a virtual portfolio. | Command | Action | |---------|--------| -| `/trade_topup [currency]` | Add fiat (VND default). Tracks cumulative invested via `totalvnd`. | +| `/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 fiat currencies (VND, USD). | +| `/trade_convert ` | Convert between currencies with bid/ask spread (0.5%). | | `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. | ## Supported Symbols diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js index 6800829..3933a41 100644 --- a/src/modules/trading/handlers.js +++ b/src/modules/trading/handlers.js @@ -15,6 +15,9 @@ import { import { getForexRate, 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; } @@ -27,31 +30,20 @@ function usageReply(ctx, usage) { return ctx.reply(`Usage: ${usage}`); } -/** /trade_topup [currency=VND] */ +/** /trade_topup — add VND to account */ export async function handleTopup(ctx, db) { const args = parseArgs(ctx); - if (args.length < 1) return usageReply(ctx, "/trade_topup [VND|USD]"); + if (args.length < 1) + return usageReply(ctx, "/trade_topup \nExample: /trade_topup 5000000"); const amount = Number(args[0]); if (!Number.isFinite(amount) || amount <= 0) 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)); - addCurrency(p, currency, amount); - if (currency === "VND") { - 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; - } + addCurrency(p, "VND", amount); + p.totalvnd += amount; await savePortfolio(db, uid(ctx), p); - const bal = p.currency[currency]; - await ctx.reply( - `Topped up ${formatCurrency(amount, currency)}.\nBalance: ${formatCurrency(bal, currency)}`, - ); + await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`); } /** /trade_buy */ @@ -126,7 +118,7 @@ export async function handleSell(ctx, db) { ); } -/** /trade_convert */ +/** /trade_convert — with bid/ask spread */ export async function handleConvert(ctx, db) { const args = parseArgs(ctx); if (args.length < 3) @@ -143,22 +135,38 @@ 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 fromRate; - let toRate; + let midRate; try { - [fromRate, toRate] = await Promise.all([getForexRate(db, from), getForexRate(db, to)]); + midRate = await getForexRate(db, "USD"); } catch { return ctx.reply("Could not fetch forex rate. Try again later."); } - if (fromRate == null || toRate == null) - return ctx.reply("Forex rate unavailable. 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 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)}`); - 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); 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)}%)`, + ); } diff --git a/src/modules/trading/index.js b/src/modules/trading/index.js index 81cdb28..7744d87 100644 --- a/src/modules/trading/index.js +++ b/src/modules/trading/index.js @@ -19,7 +19,7 @@ const tradingModule = { { name: "trade_topup", visibility: "public", - description: "Top up fiat to your trading account", + description: "Top up VND to your trading account", handler: (ctx) => handleTopup(ctx, db), }, { @@ -37,7 +37,7 @@ const tradingModule = { { name: "trade_convert", visibility: "public", - description: "Convert between fiat currencies", + description: "Convert between currencies (bid/ask spread)", handler: (ctx) => handleConvert(ctx, db), }, { diff --git a/tests/modules/trading/handlers.test.js b/tests/modules/trading/handlers.test.js index 86e9fff..4769d1b 100644 --- a/tests/modules/trading/handlers.test.js +++ b/tests/modules/trading/handlers.test.js @@ -68,22 +68,19 @@ describe("trading/handlers", () => { describe("handleTopup", () => { it("tops up VND", async () => { - const ctx = makeCtx("5000000 VND"); + const ctx = makeCtx("5000000"); await handleTopup(ctx, db); expect(ctx.reply).toHaveBeenCalledOnce(); expect(ctx.replies[0]).toContain("5.000.000 VND"); }); - it("tops up USD and tracks VND equivalent in 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 () => { + it("tracks totalvnd", async () => { const ctx = makeCtx("1000000"); 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 () => { @@ -93,16 +90,10 @@ describe("trading/handlers", () => { }); it("rejects negative amount", async () => { - const ctx = makeCtx("-100 VND"); + const ctx = makeCtx("-100"); await handleTopup(ctx, db); 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", () => { @@ -166,8 +157,10 @@ describe("trading/handlers", () => { }); describe("handleConvert", () => { - it("converts USD to VND", async () => { - const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js"); + it("converts USD to VND at bid rate (less than mid)", async () => { + const { emptyPortfolio, getPortfolio } = await import( + "../../../src/modules/trading/portfolio.js" + ); const p = emptyPortfolio(); p.currency.USD = 100; await savePortfolio(db, 42, p); @@ -175,7 +168,28 @@ describe("trading/handlers", () => { const ctx = makeCtx("50 USD VND"); await handleConvert(ctx, db); 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 () => {