feat: new UI — P&L summary cards, intraday chart, totals footer

This commit is contained in:
Manohar 2026-05-08 17:10:26 +00:00
parent 247f750de4
commit e97a2ca643

View file

@ -5,17 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Position Tracker</title> <title>Position Tracker</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style> <style>
body { background: #0f172a; color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; } body { background: #0f172a; color: #e2e8f0; font-family: system-ui, sans-serif; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; } .card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; }
.pnl-up { color: #4ade80; } .pnl-up { color: #4ade80; } .pnl-down { color: #f87171; } .pnl-flat { color: #94a3b8; }
.pnl-down { color: #f87171; }
.pnl-flat { color: #94a3b8; }
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; } .badge { padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
.badge-eq { background: #1e3a5f; color: #93c5fd; }
.badge-opt { background: #3b1f5f; color: #d8b4fe; } .badge-opt { background: #3b1f5f; color: #d8b4fe; }
.badge-fut { background: #1f3b2a; color: #86efac; } .badge-fut { background: #1f3b2a; color: #86efac; }
.badge-closed { background: #374151; color: #9ca3af; } .badge-eq { background: #1e3a5f; color: #93c5fd; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
.live-dot { animation: pulse 2s infinite; } .live-dot { animation: pulse 2s infinite; }
</style> </style>
@ -23,7 +21,7 @@
<body class="min-h-screen p-4"> <body class="min-h-screen p-4">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h1 class="text-2xl font-bold text-white">📊 Position Tracker</h1> <h1 class="text-2xl font-bold text-white">📊 Position Tracker</h1>
<p class="text-slate-400 text-sm" id="last-updated">Loading...</p> <p class="text-slate-400 text-sm" id="last-updated">Loading...</p>
@ -34,39 +32,91 @@
</div> </div>
</div> </div>
<!-- Summary cards --> <!-- P&L Summary — 3 big cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6" id="summary-cards"> <div class="grid grid-cols-3 gap-3 mb-4">
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Total P&L</p><p class="text-2xl font-bold" id="total-pnl"></p></div> <div class="card p-4 border-l-4 border-green-500">
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Open Positions</p><p class="text-2xl font-bold text-white" id="open-count"></p></div> <p class="text-slate-400 text-xs mb-1">Unrealised P&L</p>
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Alerts Today</p><p class="text-2xl font-bold text-amber-400" id="alert-count"></p></div> <p class="text-2xl font-bold" id="total-unrealised"></p>
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Last Error</p><p class="text-sm text-red-400 truncate" id="last-error">None</p></div> <p class="text-slate-500 text-xs mt-1">Open positions mark-to-market</p>
</div>
<div class="card p-4 border-l-4 border-blue-500">
<p class="text-slate-400 text-xs mb-1">Realised P&L</p>
<p class="text-2xl font-bold" id="total-realised"></p>
<p class="text-slate-500 text-xs mt-1">Closed legs today</p>
</div>
<div class="card p-4 border-l-4 border-purple-500">
<p class="text-slate-400 text-xs mb-1">Total P&L</p>
<p class="text-3xl font-bold" id="total-pnl"></p>
<p class="text-slate-500 text-xs mt-1">Unrealised + Realised</p>
</div>
</div>
<!-- Secondary stats row -->
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="card p-3">
<p class="text-slate-400 text-xs mb-1">Open Positions</p>
<p class="text-xl font-bold text-white" id="open-count"></p>
</div>
<div class="card p-3">
<p class="text-slate-400 text-xs mb-1">Alerts Today</p>
<p class="text-xl font-bold text-amber-400" id="alert-count"></p>
</div>
<div class="card p-3">
<p class="text-slate-400 text-xs mb-1">Last Error</p>
<p class="text-sm text-red-400 truncate" id="last-error">None</p>
</div>
</div>
<!-- Intraday P&L chart -->
<div class="card p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold text-white">Intraday P&L</h2>
<div class="flex gap-2 text-xs">
<button onclick="loadChart(2)" id="btn-2h" class="px-2 py-1 rounded bg-slate-600 text-slate-300 hover:bg-slate-500">2H</button>
<button onclick="loadChart(8)" id="btn-8h" class="px-2 py-1 rounded bg-indigo-600 text-white">8H</button>
<button onclick="loadChart(24)" id="btn-24h" class="px-2 py-1 rounded bg-slate-600 text-slate-300 hover:bg-slate-500">24H</button>
<button onclick="loadChart(72)" id="btn-72h" class="px-2 py-1 rounded bg-slate-600 text-slate-300 hover:bg-slate-500">3D</button>
</div>
</div>
<div style="height:200px; position:relative;">
<canvas id="pnl-chart"></canvas>
<p id="chart-empty" class="hidden text-center text-slate-500 text-sm pt-16">No history yet — populates after first poll cycle</p>
</div>
</div> </div>
<!-- Positions table --> <!-- Positions table -->
<div class="card mb-6 overflow-hidden"> <div class="card mb-4 overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700"> <div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<h2 class="font-semibold text-white">Open Positions</h2> <h2 class="font-semibold text-white">Option Positions</h2>
<div id="live-indicator" class="flex items-center gap-2 text-xs text-green-400"> <span class="flex items-center gap-2 text-xs text-green-400">
<span class="live-dot w-2 h-2 rounded-full bg-green-400 inline-block"></span> Live <span class="live-dot w-2 h-2 rounded-full bg-green-400 inline-block"></span> Live
</div> </span>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-slate-400 border-b border-slate-700"> <tr class="text-slate-400 border-b border-slate-700 text-xs">
<th class="text-left px-4 py-2">Symbol</th> <th class="text-left px-4 py-2">Symbol</th>
<th class="text-right px-4 py-2">Qty</th> <th class="text-right px-4 py-2">Qty</th>
<th class="text-right px-4 py-2">LTP</th> <th class="text-right px-4 py-2">LTP</th>
<th class="text-right px-4 py-2">Avg</th> <th class="text-right px-4 py-2">Avg</th>
<th class="text-right px-4 py-2">Unrealised P&L</th> <th class="text-right px-4 py-2">Unrealised</th>
<th class="text-right px-4 py-2">Realised</th> <th class="text-right px-4 py-2">Realised</th>
<th class="text-right px-4 py-2">Total P&L</th> <th class="text-right px-4 py-2">Total</th>
<th class="text-center px-4 py-2">Type</th>
</tr> </tr>
</thead> </thead>
<tbody id="positions-tbody"> <tbody id="positions-tbody">
<tr><td colspan="8" class="text-center py-8 text-slate-500">Loading...</td></tr> <tr><td colspan="7" class="text-center py-8 text-slate-500">Loading...</td></tr>
</tbody> </tbody>
<!-- Totals row -->
<tfoot id="positions-tfoot" class="hidden border-t-2 border-slate-600 bg-slate-800/60 font-semibold text-sm">
<tr>
<td class="px-4 py-2 text-slate-300" colspan="4">Portfolio Total</td>
<td class="px-4 py-2 text-right" id="foot-unrealised"></td>
<td class="px-4 py-2 text-right" id="foot-realised"></td>
<td class="px-4 py-2 text-right" id="foot-total"></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>
@ -79,7 +129,7 @@
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-slate-400 border-b border-slate-700"> <tr class="text-slate-400 border-b border-slate-700 text-xs">
<th class="text-left px-4 py-2">Symbol</th> <th class="text-left px-4 py-2">Symbol</th>
<th class="text-right px-4 py-2">P&L at Alert</th> <th class="text-right px-4 py-2">P&L at Alert</th>
<th class="text-right px-4 py-2">From Anchor</th> <th class="text-right px-4 py-2">From Anchor</th>
@ -89,7 +139,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="alerts-tbody"> <tbody id="alerts-tbody">
<tr><td colspan="6" class="text-center py-8 text-slate-500">Loading...</td></tr> <tr><td colspan="6" class="text-center py-8 text-slate-500">No alerts yet</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -97,87 +147,160 @@
<script> <script>
// ── helpers ────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────
const fmt = (n, decimals=0) => { const fmt = (n, d=0) => {
if (n === null || n === undefined) return '—'; if (n == null) return '—';
const abs = Math.abs(n); const s = Math.abs(n).toLocaleString('en-IN', {maximumFractionDigits:d});
const s = abs.toLocaleString('en-IN', {maximumFractionDigits: decimals});
return (n >= 0 ? '+' : '-') + '₹' + s; return (n >= 0 ? '+' : '-') + '₹' + s;
}; };
const pnlClass = n => n > 0 ? 'pnl-up' : n < 0 ? 'pnl-down' : 'pnl-flat'; const pnlClass = n => n > 0 ? 'pnl-up' : n < 0 ? 'pnl-down' : 'pnl-flat';
const toIST = utcStr => { const toIST = s => new Date(s+(s.includes('Z')?'':'Z')).toLocaleString('en-IN',
const d = new Date(utcStr + (utcStr.includes('Z') ? '' : 'Z')); {timeZone:'Asia/Kolkata', hour12:false, month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
return d.toLocaleString('en-IN', {timeZone:'Asia/Kolkata', hour12:false,
month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
};
const instrumentBadge = t => {
if (!t) return '';
if (t.includes('OPT')) return `<span class="badge badge-opt">${t}</span>`;
if (t.includes('FUT')) return `<span class="badge badge-fut">${t}</span>`;
return `<span class="badge badge-eq">${t}</span>`;
};
// ── data fetchers ───────────────────────────────────────────────────────── let chartInstance = null;
let currentHours = 8;
// ── chart ─────────────────────────────────────────────────────────────────
async function loadChart(hours=8) {
currentHours = hours;
['2','8','24','72'].forEach(h => {
const b = document.getElementById(`btn-${h}h`);
b.className = h == hours
? 'px-2 py-1 rounded bg-indigo-600 text-white'
: 'px-2 py-1 rounded bg-slate-600 text-slate-300 hover:bg-slate-500';
});
const r = await fetch(`/api/pnl-history?hours=${hours}`);
const {summary, history} = await r.json();
// Update summary cards from latest snapshot
if (summary) {
const setCard = (id, val) => {
const el = document.getElementById(id);
el.textContent = fmt(val);
el.className = 'text-2xl font-bold ' + pnlClass(val);
};
setCard('total-unrealised', summary.totalUnrealised);
setCard('total-realised', summary.totalRealised);
const tp = document.getElementById('total-pnl');
tp.textContent = fmt(summary.totalPnl);
tp.className = 'text-3xl font-bold ' + pnlClass(summary.totalPnl);
document.getElementById('open-count').textContent = summary.openPositions;
}
const canvas = document.getElementById('pnl-chart');
const empty = document.getElementById('chart-empty');
if (!history || history.length < 2) {
canvas.style.display = 'none';
empty.classList.remove('hidden');
return;
}
canvas.style.display = 'block';
empty.classList.add('hidden');
const labels = history.map(h => toIST(h.recorded_at));
const unrealised = history.map(h => h.total_unrealised);
const realised = history.map(h => h.total_realised);
const total = history.map(h => h.total_pnl);
if (chartInstance) chartInstance.destroy();
chartInstance = new Chart(canvas, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Total P&L', data: total, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.08)', tension: 0.3, pointRadius: 0, borderWidth: 2, fill: true },
{ label: 'Unrealised', data: unrealised, borderColor: '#4ade80', backgroundColor: 'transparent', tension: 0.3, pointRadius: 0, borderWidth: 1.5, borderDash: [] },
{ label: 'Realised', data: realised, borderColor: '#60a5fa', backgroundColor: 'transparent', tension: 0.3, pointRadius: 0, borderWidth: 1.5, borderDash: [4,3] },
]
},
options: {
responsive: true, maintainAspectRatio: false,
animation: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { labels: { color: '#94a3b8', boxWidth: 12, font: { size: 11 } } },
tooltip: {
backgroundColor: '#1e293b', borderColor: '#475569', borderWidth: 1,
titleColor: '#94a3b8', bodyColor: '#e2e8f0',
callbacks: {
label: ctx => ` ${ctx.dataset.label}: ${ctx.raw >= 0 ? '+' : ''}₹${Math.round(ctx.raw).toLocaleString('en-IN')}`
}
}
},
scales: {
x: { ticks: { color: '#475569', maxTicksLimit: 8, font:{size:10} }, grid: { color: '#1e293b' } },
y: { ticks: { color: '#475569', font:{size:10},
callback: v => (v>=0?'+':'')+'₹'+Math.round(v).toLocaleString('en-IN') },
grid: { color: '#334155' },
border: { dash: [4,4] } }
}
}
});
}
// ── positions ─────────────────────────────────────────────────────────────
async function loadPositions() { async function loadPositions() {
const r = await fetch('/api/positions'); const r = await fetch('/api/positions');
const {data} = await r.json(); const {data} = await r.json();
const open = data.filter(p => p.is_closed === 0); const open = data.filter(p => p.is_closed === 0);
const tbody = document.getElementById('positions-tbody'); const tbody = document.getElementById('positions-tbody');
const tfoot = document.getElementById('positions-tfoot');
document.getElementById('open-count').textContent = open.length;
const totalPnl = open.reduce((s, p) => s + p.total_pnl, 0);
const el = document.getElementById('total-pnl');
el.textContent = fmt(totalPnl);
el.className = 'text-2xl font-bold ' + pnlClass(totalPnl);
if (open.length === 0) { if (open.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-8 text-slate-500">No open positions</td></tr>`; tbody.innerHTML = `<tr><td colspan="7" class="text-center py-8 text-slate-500">No open positions</td></tr>`;
tfoot.classList.add('hidden');
return; return;
} }
const sumU = open.reduce((s,p) => s + p.unrealised_pnl, 0);
const sumR = open.reduce((s,p) => s + p.realised_pnl, 0);
const sumT = open.reduce((s,p) => s + p.total_pnl, 0);
tbody.innerHTML = open.map(p => ` tbody.innerHTML = open.map(p => `
<tr class="border-b border-slate-800 hover:bg-slate-800/50"> <tr class="border-b border-slate-800 hover:bg-slate-800/40">
<td class="px-4 py-2.5 font-medium text-white">${p.tradingsymbol}<br> <td class="px-4 py-2.5 font-medium text-white text-xs">
<span class="text-slate-500 text-xs">${p.exchange} · ${p.producttype}</span></td> ${p.tradingsymbol}<br>
<td class="px-4 py-2.5 text-right text-white">${p.netqty}</td> <span class="text-slate-500">${p.exchange} · ${p.producttype}</span>
</td>
<td class="px-4 py-2.5 text-right ${p.netqty < 0 ? 'text-red-300' : 'text-green-300'} font-medium">${p.netqty}</td>
<td class="px-4 py-2.5 text-right text-white">₹${(+p.ltp).toFixed(2)}</td> <td class="px-4 py-2.5 text-right text-white">₹${(+p.ltp).toFixed(2)}</td>
<td class="px-4 py-2.5 text-right text-slate-300">₹${(+p.avg_price).toFixed(2)}</td> <td class="px-4 py-2.5 text-right text-slate-300">₹${(+p.avg_price).toFixed(2)}</td>
<td class="px-4 py-2.5 text-right font-medium ${pnlClass(p.unrealised_pnl)}">${fmt(p.unrealised_pnl)}</td> <td class="px-4 py-2.5 text-right font-medium ${pnlClass(p.unrealised_pnl)}">${fmt(p.unrealised_pnl)}</td>
<td class="px-4 py-2.5 text-right ${pnlClass(p.realised_pnl)}">${fmt(p.realised_pnl)}</td> <td class="px-4 py-2.5 text-right ${pnlClass(p.realised_pnl)}">${fmt(p.realised_pnl)}</td>
<td class="px-4 py-2.5 text-right font-bold ${pnlClass(p.total_pnl)}">${fmt(p.total_pnl)}</td> <td class="px-4 py-2.5 text-right font-bold ${pnlClass(p.total_pnl)}">${fmt(p.total_pnl)}</td>
<td class="px-4 py-2.5 text-center">${instrumentBadge(p.instrumenttype)}</td> </tr>`).join('');
</tr>
`).join(''); // Totals footer
tfoot.classList.remove('hidden');
const set = (id, v) => { const el=document.getElementById(id); el.textContent=fmt(v); el.className=`px-4 py-2 text-right ${pnlClass(v)}`; };
set('foot-unrealised', sumU);
set('foot-realised', sumR);
set('foot-total', sumT);
} }
// ── alerts ────────────────────────────────────────────────────────────────
async function loadAlerts() { async function loadAlerts() {
const r = await fetch('/api/alerts?limit=20'); const r = await fetch('/api/alerts?limit=20');
const {data} = await r.json(); const {data} = await r.json();
const tbody = document.getElementById('alerts-tbody'); const tbody = document.getElementById('alerts-tbody');
const today = new Date().toISOString().slice(0,10); const today = new Date().toISOString().slice(0,10);
const todayCount = data.filter(a => a.alerted_at.startsWith(today)).length; document.getElementById('alert-count').textContent =
document.getElementById('alert-count').textContent = todayCount; data.filter(a => a.alerted_at.startsWith(today)).length;
if (data.length === 0) { if (!data.length) return;
tbody.innerHTML = `<tr><td colspan="6" class="text-center py-8 text-slate-500">No alerts yet</td></tr>`; tbody.innerHTML = data.map(a => `
return; <tr class="border-b border-slate-800 hover:bg-slate-800/40">
} <td class="px-4 py-2.5 font-medium text-white">${a.direction==='up'?'🟢':'🔴'} ${a.tradingsymbol}</td>
<td class="px-4 py-2.5 text-right font-medium ${pnlClass(a.current_pnl)}">${fmt(a.current_pnl)}</td>
tbody.innerHTML = data.map(a => { <td class="px-4 py-2.5 text-right text-slate-300">${fmt(a.anchor_pnl)}</td>
const dir = a.direction === 'up' ? '🟢' : '🔴'; <td class="px-4 py-2.5 text-right ${pnlClass(a.delta_abs)}">${fmt(a.delta_abs)} (${a.delta_pct>0?'+':''}${(+a.delta_pct).toFixed(1)}%)</td>
return ` <td class="px-4 py-2.5 text-right text-slate-300">₹${(+a.ltp).toFixed(2)}</td>
<tr class="border-b border-slate-800 hover:bg-slate-800/50"> <td class="px-4 py-2.5 text-right text-slate-400">${toIST(a.alerted_at)}</td>
<td class="px-4 py-2.5 font-medium text-white">${dir} ${a.tradingsymbol}</td> </tr>`).join('');
<td class="px-4 py-2.5 text-right font-medium ${pnlClass(a.current_pnl)}">${fmt(a.current_pnl)}</td>
<td class="px-4 py-2.5 text-right text-slate-300">${fmt(a.anchor_pnl)}</td>
<td class="px-4 py-2.5 text-right ${pnlClass(a.delta_abs)}">
${fmt(a.delta_abs)} (${a.delta_pct > 0 ? '+' : ''}${(+a.delta_pct).toFixed(1)}%)
</td>
<td class="px-4 py-2.5 text-right text-slate-300">₹${(+a.ltp).toFixed(2)}</td>
<td class="px-4 py-2.5 text-right text-slate-400">${toIST(a.alerted_at)}</td>
</tr>`;
}).join('');
} }
// ── health ────────────────────────────────────────────────────────────────
async function loadHealth() { async function loadHealth() {
const r = await fetch('/api/health'); const r = await fetch('/api/health');
const d = await r.json(); const d = await r.json();
@ -185,14 +308,15 @@
ms.textContent = d.marketOpen ? '🟢 Market Open' : '🔴 Market Closed'; ms.textContent = d.marketOpen ? '🟢 Market Open' : '🔴 Market Closed';
ms.className = 'badge ' + (d.marketOpen ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400'); ms.className = 'badge ' + (d.marketOpen ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400');
document.getElementById('last-error').textContent = d.lastError ? d.lastError.error.slice(0,40) : 'None'; document.getElementById('last-error').textContent = d.lastError ? d.lastError.error.slice(0,40) : 'None';
document.getElementById('last-updated').textContent = 'Updated: ' + new Date().toLocaleTimeString('en-IN', {timeZone:'Asia/Kolkata'}) + ' IST'; document.getElementById('last-updated').textContent =
'Updated: ' + new Date().toLocaleTimeString('en-IN',{timeZone:'Asia/Kolkata',hour12:false}) + ' IST';
} }
// ── main ──────────────────────────────────────────────────────────────────
async function refresh() { async function refresh() {
await Promise.all([loadPositions(), loadAlerts(), loadHealth()]); await Promise.all([loadPositions(), loadAlerts(), loadHealth(), loadChart(currentHours)]);
} }
// Initial load + auto-refresh every 60s
refresh(); refresh();
setInterval(refresh, 60_000); setInterval(refresh, 60_000);
</script> </script>