mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
feat: add fake trading module with crypto, stocks, forex and gold
Paper trading system with 5 commands (trade_topup, trade_buy, trade_sell, trade_convert, trade_stats). Supports VN stocks via TCBS, crypto via CoinGecko, forex via ER-API, and gold via PAX Gold proxy. Per-user portfolio stored in KV with 60s price caching. 54 new tests.
This commit is contained in:
@@ -13,4 +13,5 @@ export const moduleRegistry = {
|
||||
wordle: () => import("./wordle/index.js"),
|
||||
loldle: () => import("./loldle/index.js"),
|
||||
misc: () => import("./misc/index.js"),
|
||||
trading: () => import("./trading/index.js"),
|
||||
};
|
||||
|
||||
80
src/modules/trading/format.js
Normal file
80
src/modules/trading/format.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @file Number formatters for trading module display.
|
||||
* Manual VND formatter avoids locale-dependent toLocaleString in CF Workers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format number as Vietnamese Dong — dot thousands separator, no decimals.
|
||||
* @param {number} n
|
||||
* @returns {string} e.g. "15.000.000 VND"
|
||||
*/
|
||||
export function formatVND(n) {
|
||||
const rounded = Math.round(n);
|
||||
const abs = Math.abs(rounded).toString();
|
||||
// insert dots every 3 digits from right
|
||||
let result = "";
|
||||
for (let i = 0; i < abs.length; i++) {
|
||||
if (i > 0 && (abs.length - i) % 3 === 0) result += ".";
|
||||
result += abs[i];
|
||||
}
|
||||
return `${rounded < 0 ? "-" : ""}${result} VND`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number as USD — 2 decimals, comma thousands.
|
||||
* @param {number} n
|
||||
* @returns {string} e.g. "$1,234.56"
|
||||
*/
|
||||
export function formatUSD(n) {
|
||||
const fixed = Math.abs(n).toFixed(2);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
let result = "";
|
||||
for (let i = 0; i < intPart.length; i++) {
|
||||
if (i > 0 && (intPart.length - i) % 3 === 0) result += ",";
|
||||
result += intPart[i];
|
||||
}
|
||||
return `${n < 0 ? "-" : ""}$${result}.${decPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format crypto quantity — up to 8 decimals, trailing zeros stripped.
|
||||
* @param {number} n
|
||||
* @returns {string} e.g. "0.00125"
|
||||
*/
|
||||
export function formatCrypto(n) {
|
||||
return Number.parseFloat(n.toFixed(8)).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format stock quantity — integer only.
|
||||
* @param {number} n
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatStock(n) {
|
||||
return Math.floor(n).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format amount based on currency type.
|
||||
* @param {number} n
|
||||
* @param {string} currency — "VND" or "USD"
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCurrency(n, currency) {
|
||||
if (currency === "VND") return formatVND(n);
|
||||
if (currency === "USD") return formatUSD(n);
|
||||
return `${n} ${currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format P&L line with absolute and percentage.
|
||||
* @param {number} currentValue
|
||||
* @param {number} invested
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatPnL(currentValue, invested) {
|
||||
const diff = currentValue - invested;
|
||||
const pct = invested > 0 ? ((diff / invested) * 100).toFixed(2) : "0.00";
|
||||
const sign = diff >= 0 ? "+" : "";
|
||||
return `${sign}${formatVND(diff)} (${sign}${pct}%)`;
|
||||
}
|
||||
164
src/modules/trading/handlers.js
Normal file
164
src/modules/trading/handlers.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @file Command handler implementations for the trading module.
|
||||
* Each handler receives (ctx, db) — the grammY context and KV store.
|
||||
*/
|
||||
|
||||
import { formatCrypto, formatCurrency, formatStock, formatVND } from "./format.js";
|
||||
import {
|
||||
addAsset,
|
||||
addCurrency,
|
||||
deductAsset,
|
||||
deductCurrency,
|
||||
getPortfolio,
|
||||
savePortfolio,
|
||||
} from "./portfolio.js";
|
||||
import { getForexRate, getPrice } from "./prices.js";
|
||||
import { CURRENCIES, getSymbol, listSymbols } from "./symbols.js";
|
||||
|
||||
function uid(ctx) {
|
||||
return ctx.from?.id;
|
||||
}
|
||||
|
||||
function parseArgs(ctx) {
|
||||
return (ctx.match || "").trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function usageReply(ctx, usage) {
|
||||
return ctx.reply(`Usage: ${usage}`);
|
||||
}
|
||||
|
||||
/** /trade_topup <amount> [currency=VND] */
|
||||
export async function handleTopup(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 1) return usageReply(ctx, "/trade_topup <amount> [VND|USD]");
|
||||
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;
|
||||
}
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
const bal = p.currency[currency];
|
||||
await ctx.reply(
|
||||
`Topped up ${formatCurrency(amount, currency)}.\nBalance: ${formatCurrency(bal, currency)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** /trade_buy <amount> <symbol> */
|
||||
export async function handleBuy(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_buy <amount> <SYMBOL>\nExample: /trade_buy 0.01 BTC");
|
||||
const amount = Number(args[0]);
|
||||
if (!Number.isFinite(amount) || amount <= 0)
|
||||
return ctx.reply("Amount must be a positive number.");
|
||||
const info = getSymbol(args[1]);
|
||||
if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`);
|
||||
if (info.category === "stock" && !Number.isInteger(amount))
|
||||
return ctx.reply("Stock quantities must be whole numbers.");
|
||||
|
||||
let price;
|
||||
try {
|
||||
price = await getPrice(db, info.symbol);
|
||||
} catch {
|
||||
return ctx.reply("Could not fetch price. Try again later.");
|
||||
}
|
||||
if (price == null) return ctx.reply(`No price available for ${info.symbol}.`);
|
||||
|
||||
const cost = amount * price;
|
||||
const p = await getPortfolio(db, uid(ctx));
|
||||
const result = deductCurrency(p, "VND", cost);
|
||||
if (!result.ok) {
|
||||
return ctx.reply(
|
||||
`Insufficient VND. Need ${formatVND(cost)}, have ${formatVND(result.balance)}.`,
|
||||
);
|
||||
}
|
||||
addAsset(p, info.symbol, amount);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
|
||||
await ctx.reply(`Bought ${qty} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`);
|
||||
}
|
||||
|
||||
/** /trade_sell <amount> <symbol> */
|
||||
export async function handleSell(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_sell <amount> <SYMBOL>\nExample: /trade_sell 0.01 BTC");
|
||||
const amount = Number(args[0]);
|
||||
if (!Number.isFinite(amount) || amount <= 0)
|
||||
return ctx.reply("Amount must be a positive number.");
|
||||
const info = getSymbol(args[1]);
|
||||
if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`);
|
||||
if (info.category === "stock" && !Number.isInteger(amount))
|
||||
return ctx.reply("Stock quantities must be whole numbers.");
|
||||
|
||||
const p = await getPortfolio(db, uid(ctx));
|
||||
const result = deductAsset(p, info.symbol, amount);
|
||||
if (!result.ok) {
|
||||
const qty = info.category === "stock" ? formatStock(result.held) : formatCrypto(result.held);
|
||||
return ctx.reply(`Insufficient ${info.symbol}. You have: ${qty}`);
|
||||
}
|
||||
|
||||
let price;
|
||||
try {
|
||||
price = await getPrice(db, info.symbol);
|
||||
} catch {
|
||||
return ctx.reply("Could not fetch price. Try again later.");
|
||||
}
|
||||
if (price == null) return ctx.reply(`No price available for ${info.symbol}.`);
|
||||
|
||||
const revenue = amount * price;
|
||||
addCurrency(p, "VND", revenue);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount);
|
||||
await ctx.reply(
|
||||
`Sold ${qty} ${info.symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** /trade_convert <amount> <from> <to> */
|
||||
export async function handleConvert(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 3)
|
||||
return usageReply(
|
||||
ctx,
|
||||
"/trade_convert <amount> <FROM> <TO>\nExample: /trade_convert 100 USD VND",
|
||||
);
|
||||
const amount = Number(args[0]);
|
||||
if (!Number.isFinite(amount) || amount <= 0)
|
||||
return ctx.reply("Amount must be a positive number.");
|
||||
const from = args[1].toUpperCase();
|
||||
const to = args[2].toUpperCase();
|
||||
if (!CURRENCIES.has(from) || !CURRENCIES.has(to))
|
||||
return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`);
|
||||
if (from === to) return ctx.reply("Cannot convert to the same currency.");
|
||||
|
||||
let fromRate;
|
||||
let toRate;
|
||||
try {
|
||||
[fromRate, toRate] = await Promise.all([getForexRate(db, from), getForexRate(db, to)]);
|
||||
} 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.");
|
||||
|
||||
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;
|
||||
addCurrency(p, to, converted);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
await ctx.reply(`Converted ${formatCurrency(amount, from)} → ${formatCurrency(converted, to)}`);
|
||||
}
|
||||
52
src/modules/trading/index.js
Normal file
52
src/modules/trading/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @file Trading module entry — fake/paper trading with crypto, VN stocks, forex, gold.
|
||||
* Handlers live in handlers.js; this file wires them into the module system.
|
||||
*/
|
||||
|
||||
import { handleBuy, handleConvert, handleSell, handleTopup } from "./handlers.js";
|
||||
import { handleStats } from "./stats-handler.js";
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const tradingModule = {
|
||||
name: "trading",
|
||||
init: async ({ db: store }) => {
|
||||
db = store;
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "trade_topup",
|
||||
visibility: "public",
|
||||
description: "Top up fiat to your trading account",
|
||||
handler: (ctx) => handleTopup(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "trade_buy",
|
||||
visibility: "public",
|
||||
description: "Buy crypto/stock/gold at market price",
|
||||
handler: (ctx) => handleBuy(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "trade_sell",
|
||||
visibility: "public",
|
||||
description: "Sell holdings back to VND",
|
||||
handler: (ctx) => handleSell(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "trade_convert",
|
||||
visibility: "public",
|
||||
description: "Convert between fiat currencies",
|
||||
handler: (ctx) => handleConvert(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "trade_stats",
|
||||
visibility: "public",
|
||||
description: "Show portfolio summary with P&L",
|
||||
handler: (ctx) => handleStats(ctx, db),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default tradingModule;
|
||||
107
src/modules/trading/portfolio.js
Normal file
107
src/modules/trading/portfolio.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file Portfolio CRUD — per-user KV read/write and balance operations.
|
||||
* All mutations are in-memory; caller must savePortfolio() to persist.
|
||||
*/
|
||||
|
||||
import { getSymbol } from "./symbols.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Portfolio
|
||||
* @property {{ [currency: string]: number }} currency
|
||||
* @property {{ [symbol: string]: number }} stock
|
||||
* @property {{ [symbol: string]: number }} crypto
|
||||
* @property {{ [symbol: string]: number }} others
|
||||
* @property {number} totalvnd
|
||||
*/
|
||||
|
||||
/** @returns {Portfolio} */
|
||||
export function emptyPortfolio() {
|
||||
return { currency: { VND: 0, USD: 0 }, stock: {}, crypto: {}, others: {}, totalvnd: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user portfolio from KV, or return empty if first-time user.
|
||||
* Ensures all category keys exist (migration-safe).
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {number|string} userId
|
||||
* @returns {Promise<Portfolio>}
|
||||
*/
|
||||
export async function getPortfolio(db, userId) {
|
||||
const raw = await db.getJSON(`user:${userId}`);
|
||||
if (!raw) return emptyPortfolio();
|
||||
// ensure all expected keys exist
|
||||
const p = emptyPortfolio();
|
||||
p.currency = { ...p.currency, ...raw.currency };
|
||||
p.stock = { ...raw.stock };
|
||||
p.crypto = { ...raw.crypto };
|
||||
p.others = { ...raw.others };
|
||||
p.totalvnd = raw.totalvnd ?? 0;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist portfolio to KV.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {number|string} userId
|
||||
* @param {Portfolio} portfolio
|
||||
*/
|
||||
export async function savePortfolio(db, userId, portfolio) {
|
||||
await db.putJSON(`user:${userId}`, portfolio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add fiat to portfolio. Mutates in place.
|
||||
* @param {Portfolio} p
|
||||
* @param {string} currency
|
||||
* @param {number} amount
|
||||
*/
|
||||
export function addCurrency(p, currency, amount) {
|
||||
p.currency[currency] = (p.currency[currency] || 0) + amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct fiat. Returns { ok, balance } — ok=false if insufficient.
|
||||
* @param {Portfolio} p
|
||||
* @param {string} currency
|
||||
* @param {number} amount
|
||||
* @returns {{ ok: boolean, balance: number }}
|
||||
*/
|
||||
export function deductCurrency(p, currency, amount) {
|
||||
const balance = p.currency[currency] || 0;
|
||||
if (balance < amount) return { ok: false, balance };
|
||||
p.currency[currency] = balance - amount;
|
||||
return { ok: true, balance: balance - amount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add asset (stock/crypto/others) to portfolio.
|
||||
* @param {Portfolio} p
|
||||
* @param {string} symbol
|
||||
* @param {number} qty
|
||||
*/
|
||||
export function addAsset(p, symbol, qty) {
|
||||
const info = getSymbol(symbol);
|
||||
if (!info) return;
|
||||
const cat = info.category;
|
||||
p[cat][symbol] = (p[cat][symbol] || 0) + qty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct asset. Returns { ok, held } — ok=false if insufficient.
|
||||
* Removes key if balance reaches 0.
|
||||
* @param {Portfolio} p
|
||||
* @param {string} symbol
|
||||
* @param {number} qty
|
||||
* @returns {{ ok: boolean, held: number }}
|
||||
*/
|
||||
export function deductAsset(p, symbol, qty) {
|
||||
const info = getSymbol(symbol);
|
||||
if (!info) return { ok: false, held: 0 };
|
||||
const cat = info.category;
|
||||
const held = p[cat][symbol] || 0;
|
||||
if (held < qty) return { ok: false, held };
|
||||
const remaining = held - qty;
|
||||
if (remaining === 0) delete p[cat][symbol];
|
||||
else p[cat][symbol] = remaining;
|
||||
return { ok: true, held: remaining };
|
||||
}
|
||||
131
src/modules/trading/prices.js
Normal file
131
src/modules/trading/prices.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file Price fetching — CoinGecko (crypto+gold), TCBS (VN stocks), ER-API (forex).
|
||||
* Caches merged result in KV for 60s to avoid API spam.
|
||||
*/
|
||||
|
||||
import { SYMBOLS } from "./symbols.js";
|
||||
|
||||
const CACHE_KEY = "prices:latest";
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
const STALE_LIMIT_MS = 300_000; // 5 min — max age for fallback
|
||||
|
||||
/** Crypto + gold via CoinGecko free API */
|
||||
async function fetchCrypto() {
|
||||
const ids = Object.values(SYMBOLS)
|
||||
.filter((s) => s.category === "crypto" || s.category === "others")
|
||||
.map((s) => s.apiId)
|
||||
.join(",");
|
||||
const res = await fetch(
|
||||
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=vnd`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`CoinGecko ${res.status}`);
|
||||
const data = await res.json();
|
||||
const crypto = {};
|
||||
const others = {};
|
||||
for (const [sym, entry] of Object.entries(SYMBOLS)) {
|
||||
if (entry.category !== "crypto" && entry.category !== "others") continue;
|
||||
const price = data[entry.apiId]?.vnd;
|
||||
if (price == null) continue;
|
||||
if (entry.category === "crypto") crypto[sym] = price;
|
||||
else others[sym] = price;
|
||||
}
|
||||
return { crypto, others };
|
||||
}
|
||||
|
||||
/** Vietnam stock prices via TCBS public API (price in VND * 1000) */
|
||||
async function fetchStocks() {
|
||||
const tickers = Object.entries(SYMBOLS).filter(([, e]) => e.category === "stock");
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
const results = await Promise.allSettled(
|
||||
tickers.map(async ([sym, entry]) => {
|
||||
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${entry.apiId}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`TCBS ${entry.apiId} ${res.status}`);
|
||||
const json = await res.json();
|
||||
const close = json?.data?.[0]?.close;
|
||||
if (close == null) throw new Error(`TCBS ${entry.apiId} no close`);
|
||||
return { sym, price: close * 1000 };
|
||||
}),
|
||||
);
|
||||
const stock = {};
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled") stock[r.value.sym] = r.value.price;
|
||||
}
|
||||
return stock;
|
||||
}
|
||||
|
||||
/** Forex rates via open.er-api.com — VND per 1 USD */
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all prices in parallel, merge, cache in KV.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
*/
|
||||
export async function fetchPrices(db) {
|
||||
const [cryptoRes, stockRes, forexRes] = await Promise.allSettled([
|
||||
fetchCrypto(),
|
||||
fetchStocks(),
|
||||
fetchForex(),
|
||||
]);
|
||||
const merged = {
|
||||
ts: Date.now(),
|
||||
crypto: cryptoRes.status === "fulfilled" ? cryptoRes.value.crypto : {},
|
||||
stock: stockRes.status === "fulfilled" ? stockRes.value : {},
|
||||
forex: forexRes.status === "fulfilled" ? forexRes.value : {},
|
||||
others: cryptoRes.status === "fulfilled" ? cryptoRes.value.others : {},
|
||||
};
|
||||
try {
|
||||
await db.putJSON(CACHE_KEY, merged);
|
||||
} catch {
|
||||
/* best effort cache write */
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first price retrieval. Returns cached if < 60s old, else fetches fresh.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
*/
|
||||
export async function getPrices(db) {
|
||||
const cached = await db.getJSON(CACHE_KEY);
|
||||
if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached;
|
||||
try {
|
||||
return await fetchPrices(db);
|
||||
} catch {
|
||||
// fallback to stale cache if < 5 min
|
||||
if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached;
|
||||
throw new Error("Could not fetch prices. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VND price for a single symbol.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {string} symbol — uppercase, e.g. "BTC"
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
export async function getPrice(db, symbol) {
|
||||
const info = SYMBOLS[symbol];
|
||||
if (!info) return null;
|
||||
const prices = await getPrices(db);
|
||||
return prices[info.category]?.[symbol] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* VND equivalent of 1 unit of currency.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {string} currency — "VND" or "USD"
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function getForexRate(db, currency) {
|
||||
if (currency === "VND") return 1;
|
||||
const prices = await getPrices(db);
|
||||
return prices.forex?.[currency] ?? null;
|
||||
}
|
||||
63
src/modules/trading/stats-handler.js
Normal file
63
src/modules/trading/stats-handler.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file /trade_stats handler — portfolio summary with P&L breakdown.
|
||||
*/
|
||||
|
||||
import { formatCrypto, formatCurrency, formatPnL, formatStock, formatVND } from "./format.js";
|
||||
import { getPortfolio } from "./portfolio.js";
|
||||
import { getPrices } from "./prices.js";
|
||||
|
||||
/** /trade_stats — show full portfolio valued in VND with P&L */
|
||||
export async function handleStats(ctx, db) {
|
||||
const p = await getPortfolio(db, ctx.from?.id);
|
||||
let prices;
|
||||
try {
|
||||
prices = await getPrices(db);
|
||||
} catch {
|
||||
return ctx.reply("Could not fetch prices. Try again later.");
|
||||
}
|
||||
|
||||
const lines = ["📊 Portfolio Summary\n"];
|
||||
let totalValue = 0;
|
||||
|
||||
// currencies
|
||||
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 vndVal = bal * rate;
|
||||
totalValue += vndVal;
|
||||
currLines.push(
|
||||
cur === "VND"
|
||||
? ` VND: ${formatVND(bal)}`
|
||||
: ` ${cur}: ${formatCurrency(bal, cur)} (~${formatVND(vndVal)})`,
|
||||
);
|
||||
}
|
||||
if (currLines.length) lines.push("Currency:", ...currLines);
|
||||
|
||||
// asset categories
|
||||
for (const [catName, catLabel] of [
|
||||
["stock", "Stocks"],
|
||||
["crypto", "Crypto"],
|
||||
["others", "Others"],
|
||||
]) {
|
||||
const catLines = [];
|
||||
for (const [sym, qty] of Object.entries(p[catName])) {
|
||||
if (qty === 0) continue;
|
||||
const price = prices[catName]?.[sym];
|
||||
if (price == null) {
|
||||
catLines.push(` ${sym}: ${qty} (no price)`);
|
||||
continue;
|
||||
}
|
||||
const val = qty * price;
|
||||
totalValue += val;
|
||||
const fmtQty = catName === "stock" ? formatStock(qty) : formatCrypto(qty);
|
||||
catLines.push(` ${sym} x${fmtQty} @ ${formatVND(price)} = ${formatVND(val)}`);
|
||||
}
|
||||
if (catLines.length) lines.push(`\n${catLabel}:`, ...catLines);
|
||||
}
|
||||
|
||||
lines.push(`\nTotal value: ${formatVND(totalValue)}`);
|
||||
lines.push(`Invested: ${formatVND(p.totalvnd)}`);
|
||||
lines.push(`P&L: ${formatPnL(totalValue, p.totalvnd)}`);
|
||||
await ctx.reply(lines.join("\n"));
|
||||
}
|
||||
53
src/modules/trading/symbols.js
Normal file
53
src/modules/trading/symbols.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Symbol registry — hardcoded list of tradable assets + fiat currencies.
|
||||
* Adding a new asset = one line here, no logic changes elsewhere.
|
||||
*/
|
||||
|
||||
/** @typedef {{ category: "crypto"|"stock"|"others", apiId: string, label: string }} SymbolEntry */
|
||||
|
||||
/** @type {Readonly<Record<string, SymbolEntry>>} */
|
||||
export const SYMBOLS = Object.freeze({
|
||||
// crypto — CoinGecko IDs
|
||||
BTC: { category: "crypto", apiId: "bitcoin", label: "Bitcoin" },
|
||||
ETH: { category: "crypto", apiId: "ethereum", label: "Ethereum" },
|
||||
SOL: { category: "crypto", apiId: "solana", label: "Solana" },
|
||||
// Vietnam stocks — TCBS tickers
|
||||
TCB: { category: "stock", apiId: "TCB", label: "Techcombank" },
|
||||
VPB: { category: "stock", apiId: "VPB", label: "VPBank" },
|
||||
FPT: { category: "stock", apiId: "FPT", label: "FPT Corp" },
|
||||
VNM: { category: "stock", apiId: "VNM", label: "Vinamilk" },
|
||||
HPG: { category: "stock", apiId: "HPG", label: "Hoa Phat" },
|
||||
// others
|
||||
GOLD: { category: "others", apiId: "pax-gold", label: "Gold (troy oz)" },
|
||||
});
|
||||
|
||||
/** Supported fiat currencies */
|
||||
export const CURRENCIES = Object.freeze(new Set(["VND", "USD"]));
|
||||
|
||||
/**
|
||||
* Case-insensitive symbol lookup.
|
||||
* @param {string} name
|
||||
* @returns {SymbolEntry & { symbol: string } | undefined}
|
||||
*/
|
||||
export function getSymbol(name) {
|
||||
if (!name) return undefined;
|
||||
const key = name.toUpperCase();
|
||||
const entry = SYMBOLS[key];
|
||||
return entry ? { ...entry, symbol: key } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted list of all supported symbols grouped by category.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function listSymbols() {
|
||||
const groups = { crypto: [], stock: [], others: [] };
|
||||
for (const [sym, entry] of Object.entries(SYMBOLS)) {
|
||||
groups[entry.category].push(`${sym} — ${entry.label}`);
|
||||
}
|
||||
const lines = [];
|
||||
if (groups.crypto.length) lines.push("Crypto:", ...groups.crypto.map((s) => ` ${s}`));
|
||||
if (groups.stock.length) lines.push("Stocks:", ...groups.stock.map((s) => ` ${s}`));
|
||||
if (groups.others.length) lines.push("Others:", ...groups.others.map((s) => ` ${s}`));
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user