diff --git a/src/index.ts b/src/index.ts index fc31a81..9115666 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,9 @@ async function main() { cron.schedule("45 3 * * 1-5", sendMarketOpenAlert, { timezone: "UTC" }); // Pre-close alert — 3:25 IST = 9:55 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 await sendServiceNotification('start'); diff --git a/src/tracker/poll.ts b/src/tracker/poll.ts index 58bd82c..e2c5ede 100644 --- a/src/tracker/poll.ts +++ b/src/tracker/poll.ts @@ -112,7 +112,14 @@ async function evaluateAlerts(positions: Position[], today: string): Promise p.netqty !== 0); 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 if (!state || state.tradingDate !== today) { @@ -263,3 +270,80 @@ export async function sendPreCloseAlert(): Promise { 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 { + 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); + } +}