fix: snake_case->camelCase (alerts now work); feat: 2h portfolio digest cron
This commit is contained in:
parent
052db5d934
commit
c46570171a
2 changed files with 88 additions and 2 deletions
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,14 @@ async function evaluateAlerts(positions: Position[], today: string): Promise<voi
|
|||
// Only evaluate alerts for positions that are still open (netqty != 0)
|
||||
const openPositions = positions.filter(p => 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<void> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue