fix: correct booked PnL (netqty=0 only), closed positions card, no false is_closed on open qty

This commit is contained in:
Manohar 2026-05-12 04:57:18 +00:00
parent ade11564a8
commit 91fd17b158
3 changed files with 150 additions and 8 deletions

View file

@ -191,6 +191,43 @@
</table></div> </table></div>
</div> </div>
<div class="card collapsible" id="sec-closed" style="margin-bottom:16px">
<div class="card-head" onclick="toggleCard('sec-closed')">
<span class="card-title">Closed / Booked Positions</span>
<div style="display:flex;align-items:center;gap:8px">
<span id="closed-total" style="font-family:'Geist Mono',monospace;font-size:.75rem;font-weight:600;display:none"></span>
<span style="font-size:.65rem;color:var(--text3)">netqty = 0 today</span>
</div>
<span class="collapse-arrow">&#9660;</span>
</div>
<div class="collapse-body">
<table>
<thead>
<tr>
<th>Symbol</th>
<th>Type</th>
<th>Strike</th>
<th>Expiry</th>
<th>Exit LTP</th>
<th>Realised P&amp;L</th>
<th>Status</th>
</tr>
</thead>
<tbody id="closed-tbody">
<tr><td colspan="7" style="text-align:center;padding:28px;color:var(--text3)">Loading&#8230;</td></tr>
</tbody>
<tfoot id="closed-tfoot" style="display:none">
<tr>
<td colspan="5">Total Booked Today</td>
<td id="closed-foot" class="mono">&#8212;</td>
<td></td>
</tr>
</tfoot>
</table>
<div id="closed-stale" style="display:none;padding:10px 18px;font-size:.7rem;color:var(--amber);border-top:1px solid var(--border)"></div>
</div>
</div>
<div class="card collapsible" id="sec-payoff" style="margin-bottom:16px"> <div class="card collapsible" id="sec-payoff" style="margin-bottom:16px">
<div class="card-head" onclick="toggleCard('sec-payoff')"> <div class="card-head" onclick="toggleCard('sec-payoff')">
<span class="card-title">Strategy Payoff at Expiry</span> <span class="card-title">Strategy Payoff at Expiry</span>
@ -364,7 +401,7 @@ async function saveOverride(key){
const mute=document.getElementById('om-'+key)?.value; 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})}); 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 ───────────────────────────────────────────────────────── */ /* ── 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 = '<tr><td colspan="7" style="text-align:center;padding:28px;color:var(--text3)">No closed positions today</td></tr>';
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'
? '<span style="background:rgba(52,152,219,.15);color:#3498DB;padding:1px 7px;border-radius:9999px;font-size:.68rem;font-weight:600">CE</span>'
: '<span style="background:rgba(155,89,182,.15);color:#9B59B6;padding:1px 7px;border-radius:9999px;font-size:.68rem;font-weight:600">PE</span>';
return '<tr>' +
'<td><div class="sym">' + p.tradingsymbol + '</div>' +
'<div class="sym-meta">' + p.exchange + ' \u00b7 ' + p.producttype + '</div></td>' +
'<td style="text-align:center">' + typeBadge + '</td>' +
'<td class="mono" style="text-align:center">&#8377;' + parseInt(info.strike).toLocaleString('en-IN') + '</td>' +
'<td style="text-align:center;color:var(--text2);font-size:.75rem">' + info.expiry + '</td>' +
'<td class="mono">&#8377;' + parseFloat(p.ltp).toFixed(2) + '</td>' +
'<td class="mono ' + cls(p.realised_pnl) + '" style="font-weight:600">' + fmt(p.realised_pnl) + '</td>' +
'<td><span style="background:rgba(46,204,113,.12);color:var(--green);padding:2px 8px;border-radius:9999px;font-size:.65rem;font-weight:600">&#10003; Booked</span></td>' +
'</tr>';
}).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(); loadConfig();refresh();
setInterval(refresh,60000); setInterval(refresh,60000);
setInterval(loadMarket,15000); setInterval(loadMarket,15000);

View file

@ -105,12 +105,12 @@ export function createServer(): express.Application {
FROM positions WHERE is_closed = 0 AND netqty != 0 FROM positions WHERE is_closed = 0 AND netqty != 0
`).get() as any; `).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(` const closedRealised = db.prepare(`
SELECT COALESCE(SUM(realised_pnl),0) as r SELECT COALESCE(SUM(realised_pnl),0) as r
FROM positions FROM positions
WHERE (is_closed = 1 OR netqty = 0) AND realised_pnl != 0 WHERE netqty = 0 AND realised_pnl != 0
AND updated_at >= date('now')
`).get() as any; `).get() as any;
const totalUnrealised = liveOpen.u; 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 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(` const bookedRealised = (db.prepare(`
SELECT COALESCE(SUM(realised_pnl),0) as r FROM positions 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; `).get() as { r: number }).r;
res.json({ 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 ────────────────────────────────────────────────────── // ── GET /api/market ──────────────────────────────────────────────────────
app.get('/api/market', async (_req, res) => { app.get('/api/market', async (_req, res) => {
try { try {

View file

@ -70,9 +70,9 @@ export async function pollTick(): Promise<void> {
if (activeKeys.length > 0) { if (activeKeys.length > 0) {
const placeholders = activeKeys.map(() => '?').join(','); const placeholders = activeKeys.map(() => '?').join(',');
db.prepare(`UPDATE positions SET is_closed = 1, updated_at = datetime('now') 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 { } 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 // Evaluate band alerts for each open position
@ -198,7 +198,7 @@ export async function forcePoll(): Promise<void> {
} }
const activeKeys = positions.map(p => p.key); const activeKeys = positions.map(p => p.key);
if (activeKeys.length > 0) { 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); console.log(`[poll] Force fetch: ${positions.length} positions`); recordSnapshot(positions);
} catch (err) { } catch (err) {