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:
2026-04-15 13:29:15 +07:00
parent 8235c9602e
commit d040ce4161
9 changed files with 821 additions and 10 deletions

View File

@@ -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)}`,
);

View 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 150).
* 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" });
};
}

View File

@@ -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),
},
],
};

View 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);

View File

@@ -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

View 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`);
}

View File

@@ -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