Strategy Payoff at Expiry
@@ -364,7 +401,7 @@ async function saveOverride(key){
const mute=document.getElementById('om-'+key)?.value;
await fetch('/api/config/'+encodeURIComponent(key),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({alert_threshold_pct:pct?parseFloat(pct):null,muted_until:mute?new Date(mute).toISOString():null})});
}
-async function refresh(){await Promise.all([loadPositions(),loadAlerts(),loadHealth(),loadChart(curH),loadMarket(),loadPayoff()]);}
+async function refresh(){await Promise.all([loadPositions(),loadAlerts(),loadHealth(),loadChart(curH),loadMarket(),loadPayoff(),loadClosedPositions()]);}
@@ -593,6 +630,83 @@ async function loadPayoff() {
}
/* ── End Payoff ───────────────────────────────────────────────────────── */
+
+async function loadClosedPositions() {
+ var res = await fetch('/api/closed-positions').then(function(r){return r.json();}).catch(function(){return {ok:false,data:[]};});
+ var tbody = document.getElementById('closed-tbody');
+ var tfoot = document.getElementById('closed-tfoot');
+ var totalEl = document.getElementById('closed-total');
+ var staleEl = document.getElementById('closed-stale');
+ if (!tbody) return;
+
+ var data = res.data || [];
+ var totalBooked = res.totalBooked || 0;
+
+ // Update collapsed header badge
+ if (totalEl) {
+ if (data.length > 0) {
+ totalEl.textContent = fmt(totalBooked);
+ totalEl.style.display = 'inline';
+ totalEl.style.color = totalBooked >= 0 ? 'var(--green)' : 'var(--red)';
+ } else {
+ totalEl.style.display = 'none';
+ }
+ }
+
+ // Update stat card
+ var bEl = document.getElementById('s-booked');
+ if (bEl && data.length > 0) {
+ bEl.textContent = fmt(totalBooked);
+ bEl.className = 'sval ' + cls(totalBooked);
+ }
+
+ if (!data.length) {
+ tbody.innerHTML = '
| No closed positions today |
';
+ if (tfoot) tfoot.style.display = 'none';
+ return;
+ }
+
+ // Parse option symbol helper
+ function parseForDisplay(sym) {
+ var m = sym.match(/^([A-Z]+)(\d{2})(\d{1})(\d{2})(\d+)(CE|PE)$/);
+ if (m) return { ul: m[1], expiry: m[3].padStart(2,'0')+'/'+m[4]+'/20'+m[2], strike: m[5], type: m[6] };
+ return { ul: sym, expiry: '—', strike: '—', type: '—' };
+ }
+
+ tbody.innerHTML = data.map(function(p) {
+ var info = parseForDisplay(p.tradingsymbol);
+ var typeBadge = info.type === 'CE'
+ ? '
CE'
+ : '
PE';
+
+ return '
' +
+ '' + p.tradingsymbol + ' ' +
+ '' + p.exchange + ' \u00b7 ' + p.producttype + ' | ' +
+ '' + typeBadge + ' | ' +
+ '₹' + parseInt(info.strike).toLocaleString('en-IN') + ' | ' +
+ '' + info.expiry + ' | ' +
+ '₹' + parseFloat(p.ltp).toFixed(2) + ' | ' +
+ '' + fmt(p.realised_pnl) + ' | ' +
+ '✓ Booked | ' +
+ '
';
+ }).join('');
+
+ if (tfoot) {
+ tfoot.style.display = '';
+ var footEl = document.getElementById('closed-foot');
+ if (footEl) { footEl.textContent = fmt(totalBooked); footEl.className = 'mono ' + cls(totalBooked); }
+ }
+
+ // Show stale positions warning if any
+ var stale = res.stalePositions || [];
+ if (stale.length > 0 && staleEl) {
+ staleEl.style.display = 'block';
+ staleEl.textContent = '\u26a0\ufe0f ' + stale.length + ' position(s) temporarily disappeared from Angel API but still have open qty — they will reappear on next successful poll.';
+ } else if (staleEl) {
+ staleEl.style.display = 'none';
+ }
+}
+
loadConfig();refresh();
setInterval(refresh,60000);
setInterval(loadMarket,15000);
diff --git a/src/api/server.ts b/src/api/server.ts
index e6c632e..d7d2250 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -105,12 +105,12 @@ export function createServer(): express.Application {
FROM positions WHERE is_closed = 0 AND netqty != 0
`).get() as any;
- // Realised from fully-closed positions (netqty=0, is_closed=1) — booked today
+ // Realised from FULLY closed positions only (netqty=0) — booked today
+ // Do NOT include is_closed=1 with netqty!=0 (those are incorrectly marked / partially open)
const closedRealised = db.prepare(`
SELECT COALESCE(SUM(realised_pnl),0) as r
FROM positions
- WHERE (is_closed = 1 OR netqty = 0) AND realised_pnl != 0
- AND updated_at >= date('now')
+ WHERE netqty = 0 AND realised_pnl != 0
`).get() as any;
const totalUnrealised = liveOpen.u;
@@ -140,7 +140,7 @@ export function createServer(): express.Application {
const posCount = (db.prepare(`SELECT COUNT(*) as n FROM positions WHERE is_closed = 0 AND netqty != 0`).get() as { n: number }).n;
const bookedRealised = (db.prepare(`
SELECT COALESCE(SUM(realised_pnl),0) as r FROM positions
- WHERE (is_closed=1 OR netqty=0) AND realised_pnl!=0 AND updated_at>=date('now')
+ WHERE netqty=0 AND realised_pnl!=0
`).get() as { r: number }).r;
res.json({
@@ -154,6 +154,34 @@ export function createServer(): express.Application {
});
+
+ // ── GET /api/closed-positions ─────────────────────────────────────────────
+ // Positions fully closed today (netqty=0, have realised PnL)
+ // Also shows incorrectly-flagged positions (is_closed=1 but netqty!=0) for transparency
+ app.get('/api/closed-positions', (_req, res) => {
+ // Truly closed (netqty=0 with realised PnL)
+ const closed = db.prepare(`
+ SELECT tradingsymbol, exchange, producttype, instrumenttype,
+ netqty, avg_price, ltp, realised_pnl, updated_at
+ FROM positions
+ WHERE netqty = 0 AND realised_pnl != 0
+ ORDER BY ABS(realised_pnl) DESC
+ `).all();
+
+ // Potentially stale (marked closed by system but still has qty — should not happen after fix)
+ const stale = db.prepare(`
+ SELECT tradingsymbol, exchange, netqty, unrealised_pnl, realised_pnl, total_pnl, updated_at
+ FROM positions
+ WHERE is_closed = 1 AND netqty != 0
+ AND tradingsymbol NOT LIKE '%-EQ'
+ ORDER BY ABS(total_pnl) DESC
+ `).all();
+
+ const totalBooked = closed.reduce((s: number, r: any) => s + r.realised_pnl, 0);
+
+ res.json({ ok: true, data: closed, stalePositions: stale, totalBooked });
+ });
+
// ── GET /api/market ──────────────────────────────────────────────────────
app.get('/api/market', async (_req, res) => {
try {
diff --git a/src/tracker/poll.ts b/src/tracker/poll.ts
index d2bf6c8..58bd82c 100644
--- a/src/tracker/poll.ts
+++ b/src/tracker/poll.ts
@@ -70,9 +70,9 @@ export async function pollTick(): Promise
{
if (activeKeys.length > 0) {
const placeholders = activeKeys.map(() => '?').join(',');
db.prepare(`UPDATE positions SET is_closed = 1, updated_at = datetime('now')
- WHERE key NOT IN (${placeholders})`).run(...activeKeys);
+ WHERE key NOT IN (${placeholders}) AND netqty = 0`).run(...activeKeys);
} else {
- db.prepare(`UPDATE positions SET is_closed = 1, updated_at = datetime('now')`).run();
+ db.prepare(`UPDATE positions SET is_closed = 1, updated_at = datetime('now') WHERE netqty = 0`).run();
}
// Evaluate band alerts for each open position
@@ -198,7 +198,7 @@ export async function forcePoll(): Promise {
}
const activeKeys = positions.map(p => p.key);
if (activeKeys.length > 0) {
- db.prepare(`UPDATE positions SET is_closed = 1 WHERE key NOT IN (${activeKeys.map(() => "?").join(",")})`).run(...activeKeys);
+ db.prepare(`UPDATE positions SET is_closed = 1 WHERE key NOT IN (${activeKeys.map(() => "?").join(",")}) AND netqty = 0`).run(...activeKeys);
}
console.log(`[poll] Force fetch: ${positions.length} positions`); recordSnapshot(positions);
} catch (err) {