fix: snake_case->camelCase (alerts now work); feat: 2h portfolio digest cron

This commit is contained in:
Manohar 2026-05-12 05:47:59 +00:00
parent 052db5d934
commit c46570171a
2 changed files with 88 additions and 2 deletions

View file

@ -44,7 +44,9 @@ async function main() {
cron.schedule("45 3 * * 1-5", sendMarketOpenAlert, { timezone: "UTC" }); cron.schedule("45 3 * * 1-5", sendMarketOpenAlert, { timezone: "UTC" });
// Pre-close alert — 3:25 IST = 9:55 UTC // Pre-close alert — 3:25 IST = 9:55 UTC
cron.schedule("55 9 * * 1-5", sendPreCloseAlert, { timezone: "UTC" }); cron.schedule("55 9 * * 1-5", sendPreCloseAlert, { timezone: "UTC" });
console.log("[main] Market open/close alerts scheduled"); // 2h digest: 11:15 IST = 5:45 UTC, 13:15 IST = 7:45 UTC (open alert covers 9:15)
cron.schedule("45 5,7 * * 1-5", sendPortfolioDigest, { timezone: "UTC" });
console.log("[main] Market open/close alerts + 2h digest scheduled");
// 6. Notify Telegram that service started // 6. Notify Telegram that service started
await sendServiceNotification('start'); await sendServiceNotification('start');

View file

@ -112,7 +112,14 @@ async function evaluateAlerts(positions: Position[], today: string): Promise<voi
// Only evaluate alerts for positions that are still open (netqty != 0) // Only evaluate alerts for positions that are still open (netqty != 0)
const openPositions = positions.filter(p => p.netqty !== 0); const openPositions = positions.filter(p => p.netqty !== 0);
for (const pos of openPositions) { for (const pos of openPositions) {
let state = getBandState.get(pos.key) as BandState | undefined; const _raw = getBandState.get(pos.key) as any;
const state: BandState | undefined = _raw ? {
positionKey: _raw.position_key,
anchorPnl: _raw.anchor_pnl,
lastAlertPnl: _raw.last_alert_pnl,
lastAlertTime: _raw.last_alert_time ? new Date(_raw.last_alert_time) : null,
tradingDate: _raw.trading_date,
} : undefined;
// First time seeing this position, or new trading day → initialise anchor // First time seeing this position, or new trading day → initialise anchor
if (!state || state.tradingDate !== today) { if (!state || state.tradingDate !== today) {
@ -263,3 +270,80 @@ export async function sendPreCloseAlert(): Promise<void> {
console.error("[poll] Pre-close alert error:", e); console.error("[poll] Pre-close alert error:", e);
} }
} }
/**
* 2-hour portfolio digest sent to Telegram during market hours.
* Covers: P&L summary, best/worst, expiry warnings, near-alert positions, net direction.
*/
export async function sendPortfolioDigest(): Promise<void> {
try {
const positions = await fetchAllPositions();
const open = positions.filter(p => p.netqty !== 0);
if (!open.length) {
await sendTelegram("📊 *Portfolio Digest* — No open positions");
return;
}
const totalUnrealised = open.reduce((s, p) => s + p.unrealisedPnl, 0);
const totalRealised = open.reduce((s, p) => s + p.realisedPnl, 0);
const bookedRow = db.prepare(
"SELECT COALESCE(SUM(realised_pnl),0) as r FROM positions WHERE netqty=0 AND realised_pnl!=0"
).get() as { r: number };
const booked = bookedRow.r;
const totalPnl = totalUnrealised + totalRealised + booked;
const sorted = [...open].sort((a, b) => b.totalPnl - a.totalPnl);
const best = sorted[0];
const worst = sorted[sorted.length - 1];
const allBands = db.prepare("SELECT * FROM band_state").all() as any[];
const bandMap = Object.fromEntries(allBands.map((b: any) => [b.position_key, b]));
const expiryWarnings: string[] = [];
const nearAlert: string[] = [];
const now = new Date();
for (const p of open) {
const m = p.tradingsymbol.match(/^([A-Z]+)(\d{2})(\d{1})(\d{2})(\d+)(CE|PE)$/);
if (m) {
const exp = new Date(`20${m[2]}-${m[3].padStart(2,"0")}-${m[4]}`);
const dte = Math.ceil((exp.getTime() - now.getTime()) / 86400000);
if (dte <= 2 && dte >= 0)
expiryWarnings.push(`⚠️ ${p.tradingsymbol} — *${dte === 0 ? "expires TODAY" : dte + "d to expiry"}*`);
}
const band = bandMap[p.key];
if (band && Math.abs(band.anchor_pnl) >= 100) {
const pctMoved = Math.abs((p.totalPnl - band.anchor_pnl) / Math.abs(band.anchor_pnl) * 100);
const pctLeft = 5 - pctMoved;
if (pctLeft < 1.5 && pctLeft > 0)
nearAlert.push(`🔔 ${p.tradingsymbol}${pctMoved.toFixed(1)}% moved, *${pctLeft.toFixed(1)}% to alert*`);
}
}
const longs = open.filter(p => p.netqty > 0).length;
const shorts = open.filter(p => p.netqty < 0).length;
const direction = shorts > longs ? "📉 Net Short Premium" : "📈 Net Long Premium";
const f = (n: number) => (n >= 0 ? "+₹" : "-₹") + Math.abs(n).toLocaleString("en-IN", { maximumFractionDigits: 0 });
const ist = new Date().toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false });
const lines: string[] = [
`📊 *Portfolio Digest — ${ist} IST*`,
"",
"*P\\&L Summary*",
`Unrealised : ${f(totalUnrealised)}`,
`Realised : ${f(totalRealised)}`,
`Booked : ${f(booked)}`,
`*Total : ${f(totalPnl)}*`,
"",
`*Positions* — ${open.length} open | ${direction}`,
`🏆 Best : ${best.tradingsymbol} ${f(best.totalPnl)}`,
`💀 Worst: ${worst.tradingsymbol} ${f(worst.totalPnl)}`,
];
if (expiryWarnings.length) { lines.push("", "*Expiry Warnings*"); lines.push(...expiryWarnings); }
if (nearAlert.length) { lines.push("", "*Near Alert Threshold*"); lines.push(...nearAlert); }
await sendTelegram(lines.join("\n"));
} catch (e) {
console.error("[digest] Error:", e instanceof Error ? e.message : e);
}
}