From d040ce41614c52d96675e8b21bf628694a69b096 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 15 Apr 2026 13:29:15 +0700 Subject: [PATCH] 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 --- src/modules/trading/handlers.js | 22 +- src/modules/trading/history.js | 121 ++++++ src/modules/trading/index.js | 37 +- .../trading/migrations/0001_trades.sql | 11 + src/modules/trading/portfolio.js | 4 +- src/modules/trading/retention.js | 94 +++++ src/modules/trading/symbols.js | 2 +- tests/modules/trading/history.test.js | 345 ++++++++++++++++++ tests/modules/trading/retention.test.js | 195 ++++++++++ 9 files changed, 821 insertions(+), 10 deletions(-) create mode 100644 src/modules/trading/history.js create mode 100644 src/modules/trading/migrations/0001_trades.sql create mode 100644 src/modules/trading/retention.js create mode 100644 tests/modules/trading/history.test.js create mode 100644 tests/modules/trading/retention.test.js diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js index 2c9429a..af51f18 100644 --- a/src/modules/trading/handlers.js +++ b/src/modules/trading/handlers.js @@ -44,8 +44,14 @@ export async function handleTopup(ctx, db) { await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`); } -/** /trade_buy — buy VN stock at market price */ -export async function handleBuy(ctx, db) { +/** + * /trade_buy — 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) | null} [onTrade] + */ +export async function handleBuy(ctx, db, onTrade = null) { const args = parseArgs(ctx); if (args.length < 2) return usageReply(ctx, "/trade_buy \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 — sell VN stock back to VND */ -export async function handleSell(ctx, db) { +/** + * /trade_sell — 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) | null} [onTrade] + */ +export async function handleSell(ctx, db, onTrade = null) { const args = parseArgs(ctx); if (args.length < 2) return usageReply(ctx, "/trade_sell \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)}`, ); diff --git a/src/modules/trading/history.js b/src/modules/trading/history.js new file mode 100644 index 0000000..41c80be --- /dev/null +++ b/src/modules/trading/history.js @@ -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} + */ +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} + */ +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 = "Trade History\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} ${t.qty} ${sym} @ ${price} VND ${date}`; + }); + + 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} + */ +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" }); + }; +} diff --git a/src/modules/trading/index.js b/src/modules/trading/index.js index 36c4065..fa0ff20 100644 --- a/src/modules/trading/index.js +++ b/src/modules/trading/index.js @@ -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} + */ +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), + }, ], }; diff --git a/src/modules/trading/migrations/0001_trades.sql b/src/modules/trading/migrations/0001_trades.sql new file mode 100644 index 0000000..64f4a63 --- /dev/null +++ b/src/modules/trading/migrations/0001_trades.sql @@ -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); diff --git a/src/modules/trading/portfolio.js b/src/modules/trading/portfolio.js index a25ff07..df2f9bc 100644 --- a/src/modules/trading/portfolio.js +++ b/src/modules/trading/portfolio.js @@ -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 diff --git a/src/modules/trading/retention.js b/src/modules/trading/retention.js new file mode 100644 index 0000000..12191e7 --- /dev/null +++ b/src/modules/trading/retention.js @@ -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 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} + */ +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`); +} diff --git a/src/modules/trading/symbols.js b/src/modules/trading/symbols.js index 3a3b90a..4d6c778 100644 --- a/src/modules/trading/symbols.js +++ b/src/modules/trading/symbols.js @@ -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 diff --git a/tests/modules/trading/history.test.js b/tests/modules/trading/history.test.js new file mode 100644 index 0000000..af21db0 --- /dev/null +++ b/tests/modules/trading/history.test.js @@ -0,0 +1,345 @@ +/** + * @file Tests for trading/history — recordTrade, listTrades, formatTradesHtml, + * createHistoryHandler, and buy/sell → D1 integration. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSqlStore } from "../../../src/db/create-sql-store.js"; +import { + createHistoryHandler, + formatTradesHtml, + listTrades, + recordTrade, +} from "../../../src/modules/trading/history.js"; +import { makeFakeD1 } from "../../fakes/fake-d1.js"; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/** Build a SqlStore backed by a fresh fake D1, pre-seeded with the table. */ +function makeTestSql() { + const fakeDb = makeFakeD1(); + const sql = createSqlStore("trading", { DB: fakeDb }); + return { fakeDb, sql }; +} + +/** Minimal grammY ctx double. */ +function makeCtx(match = "", userId = 123) { + return { + match, + from: { id: userId }, + reply: vi.fn(), + }; +} + +/** A canned trade payload. */ +const TRADE = { userId: 1, symbol: "TCB", side: "buy", qty: 100, priceVnd: 25000 }; + +// ─── recordTrade ───────────────────────────────────────────────────────────── + +describe("recordTrade", () => { + it("inserts a row into trading_trades", async () => { + const { fakeDb, sql } = makeTestSql(); + await recordTrade(sql, TRADE); + expect(fakeDb.runLog).toHaveLength(1); + expect(fakeDb.runLog[0].query).toMatch(/INSERT INTO trading_trades/i); + const binds = fakeDb.runLog[0].binds; + expect(binds[0]).toBe(1); // user_id + expect(binds[1]).toBe("TCB"); // symbol + expect(binds[2]).toBe("buy"); // side + expect(binds[3]).toBe(100); // qty + expect(binds[4]).toBe(25000); // price_vnd + expect(typeof binds[5]).toBe("number"); // ts + }); + + it("logs a warning and returns silently when sql is null", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(recordTrade(null, TRADE)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("no D1 binding")); + warn.mockRestore(); + }); + + it("logs an error and does NOT throw when the insert fails", async () => { + const fakeDb = makeFakeD1(); + // Make prepare().bind().run() throw. + vi.spyOn(fakeDb, "prepare").mockReturnValue({ + bind: () => ({ + run: async () => { + throw new Error("disk full"); + }, + }), + }); + const sql = createSqlStore("trading", { DB: fakeDb }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(recordTrade(sql, TRADE)).resolves.toBeUndefined(); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("recordTrade failed"), + expect.any(Error), + ); + error.mockRestore(); + }); +}); + +// ─── listTrades ────────────────────────────────────────────────────────────── + +describe("listTrades", () => { + it("returns [] when sql is null", async () => { + expect(await listTrades(null, 1, 10)).toEqual([]); + }); + + it("returns [] when table is empty", async () => { + const { sql } = makeTestSql(); + expect(await listTrades(sql, 1, 10)).toEqual([]); + }); + + it("maps snake_case columns to camelCase Trade objects", async () => { + const { fakeDb, sql } = makeTestSql(); + fakeDb.seed("trading_trades", [ + { id: 1, user_id: 1, symbol: "TCB", side: "buy", qty: 10, price_vnd: 25000, ts: 1000 }, + ]); + const trades = await listTrades(sql, 1, 10); + expect(trades).toHaveLength(1); + expect(trades[0]).toMatchObject({ + id: 1, + userId: 1, + symbol: "TCB", + side: "buy", + qty: 10, + priceVnd: 25000, + ts: 1000, + }); + }); + + it("clamps limit below 1 to 1", async () => { + const { fakeDb, sql } = makeTestSql(); + fakeDb.seed("trading_trades", [ + { id: 1, user_id: 1, symbol: "A", side: "buy", qty: 1, price_vnd: 100, ts: 1 }, + ]); + // Should not throw; clamps to 1 internally — binds[1] == 1. + const trades = await listTrades(sql, 1, 0); + // fake-d1 returns all seeded rows regardless of LIMIT bind, but we verify the bind. + expect(fakeDb.queryLog[0].binds[1]).toBe(1); + }); + + it("clamps limit above 50 to 50", async () => { + const { fakeDb, sql } = makeTestSql(); + fakeDb.seed("trading_trades", []); + await listTrades(sql, 1, 999); + expect(fakeDb.queryLog[0].binds[1]).toBe(50); + }); +}); + +// ─── formatTradesHtml ──────────────────────────────────────────────────────── + +describe("formatTradesHtml", () => { + it("returns fallback message for empty array", () => { + expect(formatTradesHtml([])).toBe("No trades recorded yet."); + }); + + it("HTML-escapes symbols containing special characters", () => { + const trade = { + id: 1, + userId: 1, + symbol: "