From 5d259767a976cba3b3c485b145f7fc49be3a5049 Mon Sep 17 00:00:00 2001 From: faizal Date: Tue, 12 May 2026 15:58:34 +0800 Subject: [PATCH] Enhance notifyClose function to include an optional reason parameter. --- telegram.js | 6 +- tests/unit/telegram.test.js | 149 ++++++++++++++++++++++++++++++++++++ tools/executor.js | 2 +- 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/unit/telegram.test.js diff --git a/telegram.js b/telegram.js index 55ca29d4e..e475d92ad 100644 --- a/telegram.js +++ b/telegram.js @@ -421,12 +421,14 @@ export async function notifyDeploy({ pair, amountSol, position, tx, priceRange, ); } -export async function notifyClose({ pair, pnlUsd, pnlPct }) { +export async function notifyClose({ pair, pnlUsd, pnlPct, reason }) { if (hasActiveLiveMessage()) return; const sign = pnlUsd >= 0 ? "+" : ""; + const reasonLine = reason ? `Reason: ${reason}\n` : ""; await sendHTML( `🔒 Closed ${pair}\n` + - `PnL: ${sign}$${(pnlUsd ?? 0).toFixed(2)} (${sign}${(pnlPct ?? 0).toFixed(2)}%)` + `PnL: ${sign}$${(pnlUsd ?? 0).toFixed(2)} (${sign}${(pnlPct ?? 0).toFixed(2)}%)\n` + + reasonLine ); } diff --git a/tests/unit/telegram.test.js b/tests/unit/telegram.test.js new file mode 100644 index 000000000..2490f52bf --- /dev/null +++ b/tests/unit/telegram.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { log } from "../../logger.js"; + +vi.mock("../../logger.js", () => ({ + log: vi.fn(), +})); + +// ─── fetch mock ───────────────────────────────────────────────────────────── +let fetchCalls = []; +const mockFetch = vi.fn((url, opts) => { + fetchCalls.push({ url, opts }); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); +}); +vi.stubGlobal("fetch", mockFetch); + +// ─── env setup ────────────────────────────────────────────────────────────── +const ORIG_ENV = { ...process.env }; +beforeEach(() => { + vi.clearAllMocks(); + fetchCalls = []; + process.env.TELEGRAM_BOT_TOKEN = "test:bot123"; + process.env.TELEGRAM_CHAT_ID = "98765"; + process.env.TELEGRAM_ALLOWED_USER_IDS = "111,222"; + vi.resetModules(); +}); + +afterEach(() => { + Object.assign(process.env, ORIG_ENV); +}); + +// ─── helpers ──────────────────────────────────────────────────────────────── +function extractSentMessage() { + if (fetchCalls.length === 0) return null; + const { opts } = fetchCalls[fetchCalls.length - 1]; + const body = JSON.parse(opts.body); + return body.text; +} + +describe("notifyClose", () => { + it("includes reason below PnL when provided", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "SOL-USDC", pnlUsd: 1.23, pnlPct: 3.45, reason: "take profit: good return" }); + + const msg = extractSentMessage(); + expect(msg).toContain("🔒 Closed SOL-USDC"); + expect(msg).toContain("PnL: +$1.23 (+3.45%)"); + expect(msg).toContain("Reason: take profit: good return"); + // reason appears after PnL + const pnlIdx = msg.indexOf("PnL:"); + const reasonIdx = msg.indexOf("Reason:"); + expect(reasonIdx).toBeGreaterThan(pnlIdx); + }); + + it("omits reason line when reason is not provided (backward compat)", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "SOL-USDC", pnlUsd: 1.23, pnlPct: 3.45 }); + + const msg = extractSentMessage(); + expect(msg).toContain("🔒 Closed SOL-USDC"); + expect(msg).toContain("PnL: +$1.23 (+3.45%)"); + expect(msg).not.toContain("Reason:"); + }); + + it("omits reason line when reason is empty string", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "SOL-USDC", pnlUsd: 0.5, pnlPct: 1.2, reason: "" }); + + const msg = extractSentMessage(); + expect(msg).not.toContain("Reason:"); + }); + + it("shows + sign for positive PnL", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "BONK-SOL", pnlUsd: 5.0, pnlPct: 12.5, reason: "take profit" }); + + const msg = extractSentMessage(); + expect(msg).toContain("+$5.00 (+12.50%)"); + }); + + it("shows - sign for negative PnL", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "RKC-SOL", pnlUsd: -3.78, pnlPct: -9.71, reason: "stop loss: PnL -3.22% <= -3%" }); + + const msg = extractSentMessage(); + // sign is empty for negatives; toFixed produces "-3.78" → "$-3.78 (-9.71%)" + expect(msg).toContain("$-3.78 (-9.71%)"); + }); + + it("handles zero PnL", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "TEST-SOL", pnlUsd: 0, pnlPct: 0, reason: "breakeven" }); + + const msg = extractSentMessage(); + expect(msg).toContain("+$0.00 (+0.00%)"); + }); + + it("handles null/undefined PnL gracefully", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "TEST-SOL", pnlUsd: null, pnlPct: undefined, reason: "test" }); + + const msg = extractSentMessage(); + expect(msg).toContain("+$0.00 (+0.00%)"); + }); +}); + +describe("notifyClose reason — real-world close_reason values from lessons.json", () => { + it("displays stop loss reason from dlmm.js format", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "RKC-SOL", pnlUsd: -3.78, pnlPct: -9.71, reason: "stop loss: PnL -3.22% <= -3%" }); + + const msg = extractSentMessage(); + expect(msg).toContain("Reason: stop loss: PnL -3.22% <= -3%"); + }); + + it("displays user-requested close reason", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "ASTEROID-SOL", pnlUsd: 0.57, pnlPct: 1.46, reason: "User requested to close all positions and stop the bot" }); + + const msg = extractSentMessage(); + expect(msg).toContain("Reason: User requested to close all positions and stop the bot"); + }); + + it("displays low yield reason", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "SOL-USDC", pnlUsd: 0.12, pnlPct: 0.3, reason: "low yield" }); + + const msg = extractSentMessage(); + expect(msg).toContain("Reason: low yield"); + }); + + it("displays out-of-range close reason", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "SOL-USDC", pnlUsd: 0.33, pnlPct: 0.86, reason: "Out of range" }); + + const msg = extractSentMessage(); + expect(msg).toContain("Reason: Out of range"); + }); + + it("displays agent decision default reason", async () => { + const { notifyClose } = await import("../../telegram.js"); + await notifyClose({ pair: "JUP-SOL", pnlUsd: -1.2, pnlPct: -3.1, reason: "agent decision" }); + + const msg = extractSentMessage(); + expect(msg).toContain("Reason: agent decision"); + }); +}); diff --git a/tools/executor.js b/tools/executor.js index be556569d..f45c381cc 100644 --- a/tools/executor.js +++ b/tools/executor.js @@ -600,7 +600,7 @@ export async function executeTool(name, args) { } else if (name === "deploy_position") { notifyDeploy({ pair: result.pool_name || args.pool_name || args.pool_address?.slice(0, 8), amountSol: args.amount_y ?? args.amount_sol ?? 0, position: result.position, tx: result.txs?.[0] ?? result.tx, priceRange: result.price_range, rangeCoverage: result.range_coverage, binStep: result.bin_step, baseFee: result.base_fee }).catch(() => {}); } else if (name === "close_position") { - notifyClose({ pair: result.pool_name || args.position_address?.slice(0, 8), pnlUsd: result.pnl_usd ?? 0, pnlPct: result.pnl_pct ?? 0 }).catch(() => {}); + notifyClose({ pair: result.pool_name || args.position_address?.slice(0, 8), pnlUsd: result.pnl_usd ?? 0, pnlPct: result.pnl_pct ?? 0, reason: args.reason }).catch(() => {}); // Note low-yield closes in pool memory so screener avoids redeploying if (args.reason && args.reason.toLowerCase().includes("yield")) { const poolAddr = result.pool || args.pool_address;