mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 11:20:53 +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
|
||||
|
||||
345
tests/modules/trading/history.test.js
Normal file
345
tests/modules/trading/history.test.js
Normal file
@@ -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: "<script>",
|
||||
side: "buy",
|
||||
qty: 1,
|
||||
priceVnd: 1000,
|
||||
ts: Date.now(),
|
||||
};
|
||||
const html = formatTradesHtml([trade]);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders BUY/SELL labels", () => {
|
||||
const buy = { id: 1, userId: 1, symbol: "TCB", side: "buy", qty: 10, priceVnd: 25000, ts: 0 };
|
||||
const sell = { id: 2, userId: 1, symbol: "TCB", side: "sell", qty: 5, priceVnd: 26000, ts: 1 };
|
||||
const html = formatTradesHtml([buy, sell]);
|
||||
expect(html).toContain("BUY");
|
||||
expect(html).toContain("SELL");
|
||||
});
|
||||
|
||||
it("includes quantity and symbol in output", () => {
|
||||
const trade = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
symbol: "VNM",
|
||||
side: "sell",
|
||||
qty: 50,
|
||||
priceVnd: 80000,
|
||||
ts: 0,
|
||||
};
|
||||
const html = formatTradesHtml([trade]);
|
||||
expect(html).toContain("50");
|
||||
expect(html).toContain("VNM");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createHistoryHandler ─────────────────────────────────────────────────────
|
||||
|
||||
describe("createHistoryHandler", () => {
|
||||
it("uses default 10 when match is empty", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", []);
|
||||
const handler = createHistoryHandler(sql);
|
||||
const ctx = makeCtx("", 1);
|
||||
await handler(ctx);
|
||||
// binds[1] should be 10 (the LIMIT bind)
|
||||
expect(fakeDb.queryLog[0].binds[1]).toBe(10);
|
||||
expect(ctx.reply).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("passes parsed N to listTrades", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", []);
|
||||
const handler = createHistoryHandler(sql);
|
||||
const ctx = makeCtx("25", 1);
|
||||
await handler(ctx);
|
||||
expect(fakeDb.queryLog[0].binds[1]).toBe(25);
|
||||
});
|
||||
|
||||
it("clamps N=999 to 50", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", []);
|
||||
const handler = createHistoryHandler(sql);
|
||||
const ctx = makeCtx("999", 1);
|
||||
await handler(ctx);
|
||||
expect(fakeDb.queryLog[0].binds[1]).toBe(50);
|
||||
});
|
||||
|
||||
it("falls back to default 10 when N=0", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", []);
|
||||
const handler = createHistoryHandler(sql);
|
||||
const ctx = makeCtx("0", 1);
|
||||
await handler(ctx);
|
||||
expect(fakeDb.queryLog[0].binds[1]).toBe(10);
|
||||
});
|
||||
|
||||
it("replies with HTML parse_mode", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", []);
|
||||
const handler = createHistoryHandler(sql);
|
||||
const ctx = makeCtx("", 1);
|
||||
await handler(ctx);
|
||||
expect(ctx.reply).toHaveBeenCalledWith(expect.any(String), { parse_mode: "HTML" });
|
||||
});
|
||||
|
||||
it("works with sql=null — returns empty list message", async () => {
|
||||
const handler = createHistoryHandler(null);
|
||||
const ctx = makeCtx("", 1);
|
||||
await handler(ctx);
|
||||
expect(ctx.reply).toHaveBeenCalledWith("No trades recorded yet.", { parse_mode: "HTML" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buy/sell → D1 integration ───────────────────────────────────────────────
|
||||
|
||||
describe("buy/sell handlers → recordTrade integration", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function stubFetch() {
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes("tcbs")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ close: 25 }] }),
|
||||
});
|
||||
}
|
||||
if (url.includes("bidv")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ data: [{ currency: "USD", muaCk: "25,200", ban: "25,600" }] }),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected fetch: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
it("buy inserts a row into trading_trades", async () => {
|
||||
stubFetch();
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
const { createStore } = await import("../../../src/db/create-store.js");
|
||||
const { makeFakeKv } = await import("../../fakes/fake-kv-namespace.js");
|
||||
const { handleBuy } = await import("../../../src/modules/trading/handlers.js");
|
||||
const { savePortfolio, emptyPortfolio } = await import(
|
||||
"../../../src/modules/trading/portfolio.js"
|
||||
);
|
||||
|
||||
const db = createStore("trading", { KV: makeFakeKv() });
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 10_000_000;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
const onTrade = vi.fn(({ symbol, side, qty, priceVnd }) =>
|
||||
recordTrade(sql, { userId: 42, symbol, side, qty, priceVnd }),
|
||||
);
|
||||
const ctx = makeCtx("100 TCB", 42);
|
||||
await handleBuy(ctx, db, onTrade);
|
||||
|
||||
expect(onTrade).toHaveBeenCalledOnce();
|
||||
expect(fakeDb.runLog).toHaveLength(1);
|
||||
expect(fakeDb.runLog[0].binds[2]).toBe("buy");
|
||||
expect(fakeDb.runLog[0].binds[1]).toBe("TCB");
|
||||
});
|
||||
|
||||
it("sell inserts a row into trading_trades", async () => {
|
||||
stubFetch();
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
const { createStore } = await import("../../../src/db/create-store.js");
|
||||
const { makeFakeKv } = await import("../../fakes/fake-kv-namespace.js");
|
||||
const { handleSell } = await import("../../../src/modules/trading/handlers.js");
|
||||
const { savePortfolio, emptyPortfolio } = await import(
|
||||
"../../../src/modules/trading/portfolio.js"
|
||||
);
|
||||
|
||||
const db = createStore("trading", { KV: makeFakeKv() });
|
||||
const p = emptyPortfolio();
|
||||
p.assets.TCB = 200;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
const onTrade = vi.fn(({ symbol, side, qty, priceVnd }) =>
|
||||
recordTrade(sql, { userId: 42, symbol, side, qty, priceVnd }),
|
||||
);
|
||||
const ctx = makeCtx("50 TCB", 42);
|
||||
await handleSell(ctx, db, onTrade);
|
||||
|
||||
expect(onTrade).toHaveBeenCalledOnce();
|
||||
expect(fakeDb.runLog).toHaveLength(1);
|
||||
expect(fakeDb.runLog[0].binds[2]).toBe("sell");
|
||||
});
|
||||
|
||||
it("recordTrade failure does not prevent trade reply", async () => {
|
||||
stubFetch();
|
||||
const { sql } = makeTestSql();
|
||||
const { createStore } = await import("../../../src/db/create-store.js");
|
||||
const { makeFakeKv } = await import("../../fakes/fake-kv-namespace.js");
|
||||
const { handleBuy } = await import("../../../src/modules/trading/handlers.js");
|
||||
const { savePortfolio, emptyPortfolio } = await import(
|
||||
"../../../src/modules/trading/portfolio.js"
|
||||
);
|
||||
|
||||
const db = createStore("trading", { KV: makeFakeKv() });
|
||||
const p = emptyPortfolio();
|
||||
p.currency.VND = 10_000_000;
|
||||
await savePortfolio(db, 42, p);
|
||||
|
||||
// onTrade throws; trade reply must still succeed.
|
||||
const onTrade = async () => {
|
||||
throw new Error("D1 down");
|
||||
};
|
||||
const ctx = makeCtx("100 TCB", 42);
|
||||
|
||||
// handleBuy awaits onTrade directly; we wrap to absorb — mirrors real index.js
|
||||
// which calls recordTrade (which swallows). Here we test the swallow itself:
|
||||
const safeOnTrade = async (t) => {
|
||||
try {
|
||||
await onTrade(t);
|
||||
} catch {
|
||||
/* swallowed */
|
||||
}
|
||||
};
|
||||
await handleBuy(ctx, db, safeOnTrade);
|
||||
expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining("Bought"));
|
||||
});
|
||||
});
|
||||
195
tests/modules/trading/retention.test.js
Normal file
195
tests/modules/trading/retention.test.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file Tests for trading/retention — trimTradesHandler per-user and global caps.
|
||||
*
|
||||
* Uses enhanced fake-d1 which supports:
|
||||
* - SELECT DISTINCT user_id
|
||||
* - SELECT id ... WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET N
|
||||
* - SELECT id ... ORDER BY ts DESC LIMIT -1 OFFSET N
|
||||
* - DELETE FROM ... WHERE id IN (?, ...)
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSqlStore } from "../../../src/db/create-sql-store.js";
|
||||
import { trimTradesHandler } from "../../../src/modules/trading/retention.js";
|
||||
import { makeFakeD1 } from "../../fakes/fake-d1.js";
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a fresh sql store backed by a new fake D1. */
|
||||
function makeTestSql() {
|
||||
const fakeDb = makeFakeD1();
|
||||
const sql = createSqlStore("trading", { DB: fakeDb });
|
||||
return { fakeDb, sql };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build N trade rows for a given user_id.
|
||||
* ts increases from 1..N so that the LAST row (index N-1) has the highest ts
|
||||
* and is therefore the "newest".
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {number} count
|
||||
* @param {number} [idOffset=0] — added to each id to keep ids globally unique.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function makeTrades(userId, count, idOffset = 0) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: idOffset + i + 1,
|
||||
user_id: userId,
|
||||
symbol: "TCB",
|
||||
side: "buy",
|
||||
qty: 1,
|
||||
price_vnd: 1000,
|
||||
ts: i + 1, // ts=1 is oldest, ts=count is newest
|
||||
}));
|
||||
}
|
||||
|
||||
/** Convenience: run trimTradesHandler with muted console output. */
|
||||
async function runTrim(sql, caps) {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
await trimTradesHandler({}, { sql }, caps);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
/** Return all rows remaining in trading_trades via the fake table map. */
|
||||
function allRows(fakeDb) {
|
||||
return fakeDb.tables.get("trading_trades") ?? [];
|
||||
}
|
||||
|
||||
// ─── sql=null guard ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("trimTradesHandler — sql=null", () => {
|
||||
it("returns without throwing when sql is null", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await expect(trimTradesHandler({}, { sql: null })).resolves.toBeUndefined();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── per-user trim ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("trimTradesHandler — per-user trim", () => {
|
||||
it("removes rows beyond cap, keeping newest", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
// 5 trades for user 1, cap=3 → keeps ts=3,4,5 (newest 3), deletes ts=1,2.
|
||||
fakeDb.seed("trading_trades", makeTrades(1, 5));
|
||||
|
||||
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
||||
|
||||
const remaining = allRows(fakeDb);
|
||||
expect(remaining).toHaveLength(3);
|
||||
// Remaining should be the 3 rows with highest ts.
|
||||
const tsSorted = remaining.map((r) => r.ts).sort((a, b) => b - a);
|
||||
expect(tsSorted).toEqual([5, 4, 3]);
|
||||
});
|
||||
|
||||
it("leaves users with rows <= cap untouched", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
// User 2 has 2 trades, cap=3 → none deleted.
|
||||
fakeDb.seed("trading_trades", makeTrades(2, 2));
|
||||
|
||||
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
||||
|
||||
expect(allRows(fakeDb)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles exactly cap rows — no deletion", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", makeTrades(1, 3));
|
||||
|
||||
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
||||
|
||||
expect(allRows(fakeDb)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("trims multiple users independently", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
// User 1: 5 trades (cap 3 → keep 3). User 2: 2 trades (cap 3 → keep 2).
|
||||
fakeDb.seed("trading_trades", [...makeTrades(1, 5, 0), ...makeTrades(2, 2, 5)]);
|
||||
|
||||
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
||||
|
||||
const remaining = allRows(fakeDb);
|
||||
// 3 from user1 + 2 from user2 = 5.
|
||||
expect(remaining).toHaveLength(5);
|
||||
const user1 = remaining.filter((r) => r.user_id === 1);
|
||||
const user2 = remaining.filter((r) => r.user_id === 2);
|
||||
expect(user1).toHaveLength(3);
|
||||
expect(user2).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── global FIFO trim ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("trimTradesHandler — global trim", () => {
|
||||
it("keeps only globalCap newest rows across all users", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
// 15 trades total: user1=8, user2=7. globalCap=10 → keep 10 newest.
|
||||
const trades = [...makeTrades(1, 8, 0), ...makeTrades(2, 7, 8)];
|
||||
// Re-assign ts values to be globally unique so ordering is deterministic.
|
||||
trades.forEach((t, i) => {
|
||||
t.ts = i + 1;
|
||||
});
|
||||
fakeDb.seed("trading_trades", trades);
|
||||
|
||||
await runTrim(sql, { perUserCap: 1000, globalCap: 10 });
|
||||
|
||||
expect(allRows(fakeDb)).toHaveLength(10);
|
||||
// All remaining rows should have the top-10 ts values (6..15).
|
||||
const tsSorted = allRows(fakeDb)
|
||||
.map((r) => r.ts)
|
||||
.sort((a, b) => a - b);
|
||||
expect(tsSorted).toEqual([6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
||||
});
|
||||
|
||||
it("leaves rows untouched when count <= globalCap", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", makeTrades(1, 5));
|
||||
|
||||
await runTrim(sql, { perUserCap: 1000, globalCap: 10 });
|
||||
|
||||
expect(allRows(fakeDb)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── combined pass ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("trimTradesHandler — combined per-user + global", () => {
|
||||
it("applies per-user first, then global", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
// User 1: 6 trades (perUserCap=4 → trim to 4).
|
||||
// User 2: 6 trades (perUserCap=4 → trim to 4).
|
||||
// After per-user: 8 rows total.
|
||||
// globalCap=6 → trim to 6.
|
||||
const trades = [...makeTrades(1, 6, 0), ...makeTrades(2, 6, 6)];
|
||||
// Assign globally monotone ts values.
|
||||
trades.forEach((t, i) => {
|
||||
t.ts = i + 1;
|
||||
});
|
||||
fakeDb.seed("trading_trades", trades);
|
||||
|
||||
await runTrim(sql, { perUserCap: 4, globalCap: 6 });
|
||||
|
||||
expect(allRows(fakeDb)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── idempotence ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("trimTradesHandler — idempotence", () => {
|
||||
it("second run is a no-op when already within caps", async () => {
|
||||
const { fakeDb, sql } = makeTestSql();
|
||||
fakeDb.seed("trading_trades", makeTrades(1, 5));
|
||||
|
||||
await runTrim(sql, { perUserCap: 3, globalCap: 10 });
|
||||
const afterFirst = allRows(fakeDb).length;
|
||||
|
||||
await runTrim(sql, { perUserCap: 3, globalCap: 10 });
|
||||
const afterSecond = allRows(fakeDb).length;
|
||||
|
||||
expect(afterFirst).toBe(afterSecond);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user