mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
feat(trading): add trade history and daily FIFO retention cron
- trading_trades table (migration 0001) persists every buy/sell via optional onTrade callback - /history [n] command shows caller's last N trades (default 10, max 50), HTML-escaped - daily cron at 0 17 * * * trims to 1000/user + 10000/global via FIFO delete - persistence failure logs but does not fail the trade reply
This commit is contained in:
@@ -44,8 +44,14 @@ export async function handleTopup(ctx, db) {
|
||||
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
||||
}
|
||||
|
||||
/** /trade_buy <amount> <symbol> — buy VN stock at market price */
|
||||
export async function handleBuy(ctx, db) {
|
||||
/**
|
||||
* /trade_buy <amount> <symbol> — buy VN stock at market price.
|
||||
*
|
||||
* @param {any} ctx — grammY context.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>) | null} [onTrade]
|
||||
*/
|
||||
export async function handleBuy(ctx, db, onTrade = null) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB");
|
||||
@@ -76,13 +82,20 @@ export async function handleBuy(ctx, db) {
|
||||
}
|
||||
addAsset(p, info.symbol, amount);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
if (onTrade) await onTrade({ symbol: info.symbol, side: "buy", qty: amount, priceVnd: price });
|
||||
await ctx.reply(
|
||||
`Bought ${formatStock(amount)} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** /trade_sell <amount> <symbol> — sell VN stock back to VND */
|
||||
export async function handleSell(ctx, db) {
|
||||
/**
|
||||
* /trade_sell <amount> <symbol> — sell VN stock back to VND.
|
||||
*
|
||||
* @param {any} ctx — grammY context.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>) | null} [onTrade]
|
||||
*/
|
||||
export async function handleSell(ctx, db, onTrade = null) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB");
|
||||
@@ -107,6 +120,7 @@ export async function handleSell(ctx, db) {
|
||||
const revenue = amount * price;
|
||||
addCurrency(p, "VND", revenue);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
if (onTrade) await onTrade({ symbol, side: "sell", qty: amount, priceVnd: price });
|
||||
await ctx.reply(
|
||||
`Sold ${formatStock(amount)} ${symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
|
||||
);
|
||||
|
||||
121
src/modules/trading/history.js
Normal file
121
src/modules/trading/history.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @file history — D1-backed trade record + /history command for the trading module.
|
||||
*
|
||||
* Exports:
|
||||
* recordTrade(sql, opts) — fire-and-forget insert; logs + swallows on failure.
|
||||
* listTrades(sql, userId, limit) — newest-first query; returns [] when sql null.
|
||||
* formatTradesHtml(trades) — HTML-escaped compact list for Telegram HTML mode.
|
||||
* createHistoryHandler(sql) — grammY command handler factory.
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
|
||||
/** @typedef {import("../../types.js").Trade} Trade */
|
||||
/** @typedef {import("../../db/sql-store-interface.js").SqlStore} SqlStore */
|
||||
|
||||
const TABLE = "trading_trades";
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Insert a trade row. Silently skips when sql is null (no D1 binding).
|
||||
* Failure is logged but never re-thrown — portfolio KV is source of truth.
|
||||
*
|
||||
* @param {SqlStore | null} sql
|
||||
* @param {{ userId: number, symbol: string, side: "buy"|"sell", qty: number, priceVnd: number }} opts
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function recordTrade(sql, { userId, symbol, side, qty, priceVnd }) {
|
||||
if (sql === null) {
|
||||
console.warn("[trading/history] recordTrade skipped — no D1 binding");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sql.run(
|
||||
`INSERT INTO ${TABLE} (user_id, symbol, side, qty, price_vnd, ts) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
userId,
|
||||
symbol,
|
||||
side,
|
||||
qty,
|
||||
priceVnd,
|
||||
Date.now(),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[trading/history] recordTrade failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the most recent trades for a user, newest first.
|
||||
* Returns [] when sql is null or the table is empty.
|
||||
*
|
||||
* @param {SqlStore | null} sql
|
||||
* @param {number} userId
|
||||
* @param {number} limit — clamped to [1, 50].
|
||||
* @returns {Promise<Trade[]>}
|
||||
*/
|
||||
export async function listTrades(sql, userId, limit) {
|
||||
if (sql === null) return [];
|
||||
const n = Math.max(1, Math.min(MAX_LIMIT, limit));
|
||||
const rows = await sql.all(
|
||||
`SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM ${TABLE} WHERE user_id = ? ORDER BY ts DESC LIMIT ?`,
|
||||
userId,
|
||||
n,
|
||||
);
|
||||
// Map snake_case DB columns → camelCase Trade objects.
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
symbol: r.symbol,
|
||||
side: r.side,
|
||||
qty: r.qty,
|
||||
priceVnd: r.price_vnd,
|
||||
ts: r.ts,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a trade list as Telegram HTML.
|
||||
* Symbols are HTML-escaped to prevent injection.
|
||||
*
|
||||
* @param {Trade[]} trades
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTradesHtml(trades) {
|
||||
if (trades.length === 0) return "No trades recorded yet.";
|
||||
|
||||
const header = "<b>Trade History</b>\n";
|
||||
const lines = trades.map((t) => {
|
||||
const side = t.side === "buy" ? "BUY " : "SELL";
|
||||
const sym = escapeHtml(t.symbol);
|
||||
const price = t.priceVnd.toLocaleString("vi-VN");
|
||||
const date = new Date(t.ts).toISOString().slice(0, 16).replace("T", " ");
|
||||
return `${side} <b>${t.qty}</b> <code>${sym}</code> @ ${price} VND <i>${date}</i>`;
|
||||
});
|
||||
|
||||
return header + lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that returns a grammY command handler for /history [n].
|
||||
*
|
||||
* Parses optional N from ctx.match (default 10, clamped 1–50).
|
||||
* Replies with HTML trade list.
|
||||
*
|
||||
* @param {SqlStore | null} sql
|
||||
* @returns {(ctx: any) => Promise<void>}
|
||||
*/
|
||||
export function createHistoryHandler(sql) {
|
||||
return async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return ctx.reply("Could not identify user.");
|
||||
|
||||
const raw = Number.parseInt((ctx.match || "").trim(), 10);
|
||||
// Invalid / zero / negative → default; > MAX_LIMIT → clamp inside listTrades.
|
||||
const n = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LIMIT;
|
||||
|
||||
const trades = await listTrades(sql, userId, n);
|
||||
const html = formatTradesHtml(trades);
|
||||
await ctx.reply(html, { parse_mode: "HTML" });
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,33 @@
|
||||
*/
|
||||
|
||||
import { handleBuy, handleConvert, handleSell, handleTopup } from "./handlers.js";
|
||||
import { createHistoryHandler, recordTrade } from "./history.js";
|
||||
import { trimTradesHandler } from "./retention.js";
|
||||
import { handleStats } from "./stats-handler.js";
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
|
||||
/** @type {import("../../db/sql-store-interface.js").SqlStore | null} */
|
||||
let sql = null;
|
||||
|
||||
/**
|
||||
* Build an onTrade callback bound to the current sql store and userId.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @returns {(trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>}
|
||||
*/
|
||||
function makeOnTrade(userId) {
|
||||
return ({ symbol, side, qty, priceVnd }) =>
|
||||
recordTrade(sql, { userId, symbol, side, qty, priceVnd });
|
||||
}
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const tradingModule = {
|
||||
name: "trading",
|
||||
init: async ({ db: store }) => {
|
||||
init: async ({ db: store, sql: sqlStore }) => {
|
||||
db = store;
|
||||
sql = sqlStore ?? null;
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
@@ -26,13 +43,13 @@ const tradingModule = {
|
||||
name: "trade_buy",
|
||||
visibility: "public",
|
||||
description: "Buy VN stock at market price",
|
||||
handler: (ctx) => handleBuy(ctx, db),
|
||||
handler: (ctx) => handleBuy(ctx, db, makeOnTrade(ctx.from?.id)),
|
||||
},
|
||||
{
|
||||
name: "trade_sell",
|
||||
visibility: "public",
|
||||
description: "Sell VN stock back to VND",
|
||||
handler: (ctx) => handleSell(ctx, db),
|
||||
handler: (ctx) => handleSell(ctx, db, makeOnTrade(ctx.from?.id)),
|
||||
},
|
||||
{
|
||||
name: "trade_convert",
|
||||
@@ -46,6 +63,20 @@ const tradingModule = {
|
||||
description: "Show portfolio summary with P&L",
|
||||
handler: (ctx) => handleStats(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
visibility: "public",
|
||||
description: "Show your last N trades (default 10, max 50)",
|
||||
// handler is created lazily so it picks up the sql value set in init().
|
||||
handler: (ctx) => createHistoryHandler(sql)(ctx),
|
||||
},
|
||||
],
|
||||
crons: [
|
||||
{
|
||||
schedule: "0 17 * * *",
|
||||
name: "trim-trades",
|
||||
handler: (event, ctx) => trimTradesHandler(event, ctx),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
11
src/modules/trading/migrations/0001_trades.sql
Normal file
11
src/modules/trading/migrations/0001_trades.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE trading_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL CHECK (side IN ('buy','sell')),
|
||||
qty INTEGER NOT NULL,
|
||||
price_vnd INTEGER NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_trading_trades_user_ts ON trading_trades(user_id, ts DESC);
|
||||
CREATE INDEX idx_trading_trades_ts ON trading_trades(ts);
|
||||
@@ -7,10 +7,10 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PortfolioMeta
|
||||
* @typedef {object} PortfolioMeta
|
||||
* @property {number} invested — cumulative VND value of all top-ups (cost basis for P&L)
|
||||
*
|
||||
* @typedef {Object} Portfolio
|
||||
* @typedef {object} Portfolio
|
||||
* @property {{ [currency: string]: number }} currency
|
||||
* @property {{ [symbol: string]: number }} assets
|
||||
* @property {PortfolioMeta} meta
|
||||
|
||||
94
src/modules/trading/retention.js
Normal file
94
src/modules/trading/retention.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file retention — daily cron handler that trims trading_trades to enforce row caps.
|
||||
*
|
||||
* Strategy (two-pass):
|
||||
* 1. Per-user pass: for each distinct user_id, delete rows beyond PER_USER_CAP
|
||||
* (keeps the newest N rows per user).
|
||||
* 2. Global FIFO pass: delete any rows beyond GLOBAL_CAP across all users
|
||||
* (keeps the newest N rows globally).
|
||||
*
|
||||
* Uses a hybrid SELECT-then-DELETE approach so it works with both the real
|
||||
* Cloudflare D1 binding and the in-memory fake-d1 used in unit tests.
|
||||
*
|
||||
* @typedef {import("../../db/sql-store-interface.js").SqlStore} SqlStore
|
||||
*/
|
||||
|
||||
const TABLE = "trading_trades";
|
||||
|
||||
/** Default per-user row cap. Exported for testability. */
|
||||
export const PER_USER_CAP = 1000;
|
||||
|
||||
/** Default global row cap across all users. Exported for testability. */
|
||||
export const GLOBAL_CAP = 10000;
|
||||
|
||||
/**
|
||||
* Build a dynamic `DELETE FROM <table> WHERE id IN (?, ?, ...)` query.
|
||||
* Returns [query, ids] tuple — ids passed as spread binds.
|
||||
*
|
||||
* @param {number[]} ids
|
||||
* @returns {[string, number[]]}
|
||||
*/
|
||||
function buildDeleteByIds(ids) {
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
return [`DELETE FROM ${TABLE} WHERE id IN (${placeholders})`, ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily cron handler — trims trading_trades to enforce per-user and global caps.
|
||||
*
|
||||
* @param {any} _event — Cloudflare ScheduledEvent (unused; present for handler contract).
|
||||
* @param {{ sql: SqlStore | null }} ctx
|
||||
* @param {{ perUserCap?: number, globalCap?: number }} [caps] — override caps (for tests).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function trimTradesHandler(_event, { sql }, caps = {}) {
|
||||
if (sql === null) {
|
||||
console.log("[trim-trades] no D1 binding — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const perUserCap = caps.perUserCap ?? PER_USER_CAP;
|
||||
const globalCap = caps.globalCap ?? GLOBAL_CAP;
|
||||
|
||||
let perUserDeleted = 0;
|
||||
|
||||
// ── Pass 1: per-user trim ──────────────────────────────────────────────────
|
||||
const userRows = await sql.all(`SELECT DISTINCT user_id FROM ${TABLE}`);
|
||||
|
||||
for (const row of userRows) {
|
||||
const userId = row.user_id;
|
||||
|
||||
// Fetch IDs of rows that exceed the per-user cap (oldest rows = beyond OFFSET perUserCap).
|
||||
const excessRows = await sql.all(
|
||||
`SELECT id FROM ${TABLE} WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET ?`,
|
||||
userId,
|
||||
perUserCap,
|
||||
);
|
||||
|
||||
if (excessRows.length === 0) continue;
|
||||
|
||||
const ids = excessRows.map((r) => r.id);
|
||||
const [query, binds] = buildDeleteByIds(ids);
|
||||
const result = await sql.run(query, ...binds);
|
||||
perUserDeleted += result.changes ?? ids.length;
|
||||
}
|
||||
|
||||
console.log(`[trim-trades] per-user pass: deleted ${perUserDeleted} rows`);
|
||||
|
||||
// ── Pass 2: global FIFO trim ───────────────────────────────────────────────
|
||||
const globalExcess = await sql.all(
|
||||
`SELECT id FROM ${TABLE} ORDER BY ts DESC LIMIT -1 OFFSET ?`,
|
||||
globalCap,
|
||||
);
|
||||
|
||||
let globalDeleted = 0;
|
||||
if (globalExcess.length > 0) {
|
||||
const ids = globalExcess.map((r) => r.id);
|
||||
const [query, binds] = buildDeleteByIds(ids);
|
||||
const result = await sql.run(query, ...binds);
|
||||
globalDeleted = result.changes ?? ids.length;
|
||||
}
|
||||
|
||||
console.log(`[trim-trades] global pass: deleted ${globalDeleted} rows`);
|
||||
console.log(`[trim-trades] total deleted: ${perUserDeleted + globalDeleted} rows`);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
const COMING_SOON = "Crypto, gold & currency exchange coming soon!";
|
||||
|
||||
/**
|
||||
* @typedef {Object} ResolvedSymbol
|
||||
* @typedef {object} ResolvedSymbol
|
||||
* @property {string} symbol — uppercase ticker
|
||||
* @property {string} category — "stock" (only supported category for now)
|
||||
* @property {string} label — company name
|
||||
|
||||
Reference in New Issue
Block a user