From 247f750de4aa2e1ba8afdd700ff023c10702ab67 Mon Sep 17 00:00:00 2001 From: Manohar Date: Fri, 8 May 2026 17:10:17 +0000 Subject: [PATCH] feat: P&L snapshots table, /api/pnl-history, intraday chart + totals row in UI --- src/api/server.ts | 24 ++++++++++++++++++++++++ src/db/client.ts | 11 +++++++++++ src/tracker/poll.ts | 19 +++++++++++++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 454c7f4..bd0104f 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -86,6 +86,30 @@ export function createServer(): express.Application { res.json({ ok: true, message: 'Refresh complete' }); }); + // ── GET /api/pnl-history ────────────────────────────────────────────────── + // Returns P&L snapshots for charting. ?hours=N (default 8, max 72) + app.get('/api/pnl-history', (_req, res) => { + const hours = Math.min(parseInt(_req.query.hours as string) || 8, 72); + const rows = db.prepare(` + SELECT total_unrealised, total_realised, total_pnl, open_positions, recorded_at + FROM pnl_snapshots + WHERE recorded_at >= datetime('now', '-' || ? || ' hours') + ORDER BY recorded_at ASC + `).all(hours); + const latest = rows[rows.length - 1] as any; + res.json({ + ok: true, + summary: latest ? { + totalUnrealised: latest.total_unrealised, + totalRealised: latest.total_realised, + totalPnl: latest.total_pnl, + openPositions: latest.open_positions, + asOf: latest.recorded_at, + } : null, + history: rows, + }); + }); + app.get('/api/health', (_req, res) => { const lastError = db.prepare( `SELECT error, occurred_at FROM poll_errors ORDER BY occurred_at DESC LIMIT 1` diff --git a/src/db/client.ts b/src/db/client.ts index 609b7a1..1157407 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -79,6 +79,17 @@ export function initDb(): void { CREATE INDEX IF NOT EXISTS idx_alerts_position ON alerts(position_key); CREATE INDEX IF NOT EXISTS idx_alerts_alerted_at ON alerts(alerted_at DESC); CREATE INDEX IF NOT EXISTS idx_positions_closed ON positions(is_closed); + + -- Portfolio-level P&L snapshots (recorded every poll tick) + CREATE TABLE IF NOT EXISTS pnl_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + total_unrealised REAL NOT NULL, + total_realised REAL NOT NULL, + total_pnl REAL NOT NULL, -- unrealised + realised + open_positions INTEGER NOT NULL, + recorded_at TEXT NOT NULL -- UTC datetime + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_recorded ON pnl_snapshots(recorded_at DESC); `); console.log(`[db] Initialised at ${DB_PATH}`); diff --git a/src/tracker/poll.ts b/src/tracker/poll.ts index b7dacbb..86c55c6 100644 --- a/src/tracker/poll.ts +++ b/src/tracker/poll.ts @@ -76,7 +76,7 @@ export async function pollTick(): Promise { } // Evaluate band alerts for each open position - await evaluateAlerts(positions, today); + await evaluateAlerts(positions, today); recordSnapshot(positions); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -198,8 +198,23 @@ export async function forcePoll(): Promise { if (activeKeys.length > 0) { db.prepare(`UPDATE positions SET is_closed = 1 WHERE key NOT IN (${activeKeys.map(() => "?").join(",")})`).run(...activeKeys); } - console.log(`[poll] Force fetch: ${positions.length} positions`); + console.log(`[poll] Force fetch: ${positions.length} positions`); recordSnapshot(positions); } catch (err) { console.error(`[poll] Force fetch error: ${err instanceof Error ? err.message : err}`); } } + +/** + * Record a portfolio-level P&L snapshot after every fetch. + * Called by both pollTick (market hours) and forcePoll (manual/startup). + */ +function recordSnapshot(positions: import(../angel/types.js).Position[]): void { + const totalUnrealised = positions.reduce((s, p) => s + p.unrealisedPnl, 0); + const totalRealised = positions.reduce((s, p) => s + p.realisedPnl, 0); + const totalPnl = totalUnrealised + totalRealised; + + db.prepare(` + INSERT INTO pnl_snapshots (total_unrealised, total_realised, total_pnl, open_positions, recorded_at) + VALUES (?, ?, ?, ?, datetime(now)) + `).run(totalUnrealised, totalRealised, totalPnl, positions.length); +}