feat: strategy payoff graph — breakeven, max P&L, R/R, spot line, profit/loss shading

This commit is contained in:
Manohar 2026-05-11 05:01:23 +00:00
parent 7cf1de885e
commit 4b0bfb2a12

View file

@ -8,6 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
<style> <style>
:root{--coral:#FF6B4A;--amber:#F5A623;--green:#2ECC71;--red:#E74C3C;--tr:.22s cubic-bezier(.4,0,.2,1)} :root{--coral:#FF6B4A;--amber:#F5A623;--green:#2ECC71;--red:#E74C3C;--tr:.22s cubic-bezier(.4,0,.2,1)}
[data-theme="dark"]{--bg:#0D1117;--bg2:#161B22;--bg3:#1C2330;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#E6EDF3;--text2:#8B949E;--text3:#3D444D;--card:rgba(22,27,34,.97);--shadow:0 4px 28px rgba(0,0,0,.45);--shadow-lg:0 8px 48px rgba(0,0,0,.6);--glass:rgba(22,27,34,.8)} [data-theme="dark"]{--bg:#0D1117;--bg2:#161B22;--bg3:#1C2330;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#E6EDF3;--text2:#8B949E;--text3:#3D444D;--card:rgba(22,27,34,.97);--shadow:0 4px 28px rgba(0,0,0,.45);--shadow-lg:0 8px 48px rgba(0,0,0,.6);--glass:rgba(22,27,34,.8)}
@ -132,6 +133,16 @@
.pcard.pt .pval { font-size: 1.7rem; } .pcard.pt .pval { font-size: 1.7rem; }
} }
.pf-stat{padding:12px 16px;border-right:1px solid var(--border);text-align:center}
.pf-stat:last-child{border-right:none}
.pfs-lbl{font-size:.6rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3);margin-bottom:5px}
.pfs-val{font-family:'DM Serif Display',serif;font-size:1.1rem;color:var(--text)}
@media(max-width:768px){
#sec-payoff .pf-stat{grid-column:span 2}
#sec-payoff [style*="grid-template-columns:repeat(6"]{grid-template-columns:repeat(2,1fr)!important}
}
</style> </style>
</head> </head>
<body> <body>
@ -190,6 +201,40 @@
<tfoot id="pos-foot" style="display:none"><tr><td colspan="4">Portfolio Total</td><td id="f-u" class="mono">&#8212;</td><td id="f-r" class="mono">&#8212;</td><td id="f-t" class="mono">&#8212;</td></tr></tfoot> <tfoot id="pos-foot" style="display:none"><tr><td colspan="4">Portfolio Total</td><td id="f-u" class="mono">&#8212;</td><td id="f-r" class="mono">&#8212;</td><td id="f-t" class="mono">&#8212;</td></tr></tfoot>
</table></div> </table></div>
</div> </div>
<div class="card collapsible" id="sec-payoff" style="margin-bottom:16px">
<div class="card-head" onclick="toggleCard('sec-payoff')">
<span class="card-title">Strategy Payoff at Expiry</span>
<div style="display:flex;align-items:center;gap:8px">
<span id="pf-underlying" style="font-size:.7rem;font-weight:600;color:var(--text2);font-family:'Geist Mono',monospace"></span>
<span id="pf-strategy" style="font-size:.68rem;padding:2px 8px;border-radius:9999px;background:var(--bg3);border:1px solid var(--border2);color:var(--text2)"></span>
</div>
<span class="collapse-arrow">&#9660;</span>
</div>
<div class="collapse-body">
<!-- Stats row -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0;border-bottom:1px solid var(--border)">
<div class="pf-stat" id="pfs-credit"><div class="pfs-lbl">NET PREMIUM</div><div class="pfs-val">&#8212;</div></div>
<div class="pf-stat" id="pfs-maxp"><div class="pfs-lbl">MAX PROFIT</div><div class="pfs-val up">&#8212;</div></div>
<div class="pf-stat" id="pfs-maxl"><div class="pfs-lbl">MAX LOSS</div><div class="pfs-val dn">&#8212;</div></div>
<div class="pf-stat" id="pfs-rr"><div class="pfs-lbl">REWARD/RISK</div><div class="pfs-val">&#8212;</div></div>
<div class="pf-stat" id="pfs-be"><div class="pfs-lbl">BREAKEVEN(S)</div><div class="pfs-val" style="font-size:.78rem">&#8212;</div></div>
<div class="pf-stat" id="pfs-dte"><div class="pfs-lbl">DTE</div><div class="pfs-val">&#8212;</div></div>
</div>
<!-- Chart -->
<div style="padding:16px 16px 12px;height:260px;position:relative">
<canvas id="payoff-chart"></canvas>
<div id="payoff-empty" style="display:none;position:absolute;inset:0;align-items:center;justify-content:center;color:var(--text3);font-size:.8rem;flex-direction:column;gap:6px">
<span style="font-size:1.4rem">&#128200;</span>
<span>No option positions to analyse</span>
</div>
</div>
<div style="padding:0 16px 12px;font-size:.65rem;color:var(--text3)">
Theoretical payoff at expiry based on avg cost. Does not account for time value or IV changes.
Current spot shown as dashed line.
</div>
</div>
</div>
<div class="card collapsible" id="sec-alrt" style="margin-bottom:16px"> <div class="card collapsible" id="sec-alrt" style="margin-bottom:16px">
<div class="card-head" onclick="toggleCard('sec-alrt')"><span class="card-title">Alert History</span><div style="display:flex;align-items:center;gap:8px"><span id="alrt-hcount" style="font-size:.7rem;font-weight:600;padding:2px 9px;border-radius:9999px;background:rgba(245,166,35,.15);color:var(--amber);display:none"></span><span id="alrt-hmuted" style="font-size:.7rem;font-weight:600;padding:2px 9px;border-radius:9999px;background:rgba(231,76,60,.12);color:var(--red);display:none">MUTED</span></div><span class="collapse-arrow">&#9660;</span></div> <div class="card-head" onclick="toggleCard('sec-alrt')"><span class="card-title">Alert History</span><div style="display:flex;align-items:center;gap:8px"><span id="alrt-hcount" style="font-size:.7rem;font-weight:600;padding:2px 9px;border-radius:9999px;background:rgba(245,166,35,.15);color:var(--amber);display:none"></span><span id="alrt-hmuted" style="font-size:.7rem;font-weight:600;padding:2px 9px;border-radius:9999px;background:rgba(231,76,60,.12);color:var(--red);display:none">MUTED</span></div><span class="collapse-arrow">&#9660;</span></div>
<div class="collapse-body"><table> <div class="collapse-body"><table>
@ -223,6 +268,7 @@
</div> </div>
</div> </div>
<script> <script>
if(window.ChartAnnotation)Chart.register(window.ChartAnnotation);
let theme=localStorage.getItem('theme')||'dark'; let theme=localStorage.getItem('theme')||'dark';
document.documentElement.setAttribute('data-theme',theme); document.documentElement.setAttribute('data-theme',theme);
document.getElementById('theme-btn').textContent=theme==='dark'?'\u2600\ufe0f':'\ud83c\udf19'; document.getElementById('theme-btn').textContent=theme==='dark'?'\u2600\ufe0f':'\ud83c\udf19';
@ -336,7 +382,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()]);} async function refresh(){await Promise.all([loadPositions(),loadAlerts(),loadHealth(),loadChart(curH),loadMarket(),loadPayoff()]);}
@ -354,6 +400,226 @@ function toggleCard(id){
Object.entries(s).forEach(([id,c])=>{if(c){const el=document.getElementById(id);if(el)el.classList.add('collapsed');}}); Object.entries(s).forEach(([id,c])=>{if(c){const el=document.getElementById(id);if(el)el.classList.add('collapsed');}});
}catch(e){} }catch(e){}
})(); })();
// ── Payoff Graph ──────────────────────────────────────────────────────────────
let payoffChartInst = null;
function parseOptionSym(sym) {
// Angel format: UNDERLYING + YY + M(1digit) + DD + STRIKE + CE/PE
// e.g. SENSEX2651479000CE → underlying=SENSEX, yy=26, m=5, dd=14, strike=79000, type=CE
let m = sym.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]}`);
return { underlying: m[1], strike: parseInt(m[5]), type: m[6], expiry: exp };
}
// Fallback: UNDERLYING + YYMMDD + STRIKE + CE/PE (2-digit month)
m = sym.match(/^([A-Z]+)(d{2})(d{2})(d{2})(d+)(CE|PE)$/);
if (m) {
const exp = new Date(`20${m[2]}-${m[3]}-${m[4]}`);
return { underlying: m[1], strike: parseInt(m[5]), type: m[6], expiry: exp };
}
return null;
}
function calcPayoff(positions, spot) {
let total = 0;
for (const p of positions) {
const info = parseOptionSym(p.tradingsymbol);
if (!info) continue;
const intrinsic = info.type === 'CE' ? Math.max(spot - info.strike, 0) : Math.max(info.strike - spot, 0);
// payoff per unit at expiry = intrinsic - avg_price (for long)
// netqty: positive=long, negative=short → multiply directly
total += (intrinsic - p.avg_price) * p.netqty;
}
return total;
}
function detectStrategy(positions) {
const calls = positions.filter(p => p._parsed?.type === 'CE');
const puts = positions.filter(p => p._parsed?.type === 'PE');
const longs = positions.filter(p => p.netqty > 0);
const shorts = positions.filter(p => p.netqty < 0);
if (positions.length === 1) return longs.length ? 'Long ' + (calls.length ? 'Call' : 'Put') : 'Short ' + (calls.length ? 'Call' : 'Put');
if (calls.length === 2 && puts.length === 0) return longs.length === 1 && shorts.length === 1 ? 'Bull Call Spread' : 'Call Spread';
if (puts.length === 2 && calls.length === 0) return longs.length === 1 && shorts.length === 1 ? 'Bear Put Spread' : 'Put Spread';
if (calls.length >= 1 && puts.length >= 1 && shorts.length === positions.length) return 'Short Strangle/Straddle';
if (calls.length >= 1 && puts.length >= 1 && longs.length === positions.length) return 'Long Strangle/Straddle';
if (positions.length === 4) return 'Iron Condor / Butterfly';
return positions.length + '-Leg Strategy';
}
async function loadPayoff() {
const [posRes, mktRes] = await Promise.all([
fetch('/api/positions').then(r=>r.json()).catch(()=>({data:[]})),
fetch('/api/market').then(r=>r.json()).catch(()=>({data:[]})),
]);
const canvas = document.getElementById('payoff-chart');
const emptyEl = document.getElementById('payoff-empty');
const open = (posRes.data || []).filter(p => p.is_closed === 0);
// Only option positions (have CE/PE in symbol)
const opts = open.filter(p => /CE|PE/.test(p.tradingsymbol)).map(p => {
p._parsed = parseOptionSym(p.tradingsymbol);
return p;
}).filter(p => p._parsed);
if (!opts.length) {
canvas.style.display = 'none';
emptyEl.style.display = 'flex';
['pfs-credit','pfs-maxp','pfs-maxl','pfs-rr','pfs-be','pfs-dte'].forEach(id => {
document.querySelector('#'+id+' .pfs-val').textContent = '—';
});
return;
}
canvas.style.display = 'block';
emptyEl.style.display = 'none';
// Get current spot from market data
const mktMap = Object.fromEntries((mktRes.data || []).map(q => [q.key, q.price]));
const underlying = opts[0]._parsed.underlying;
const mktKey = underlying === 'SENSEX' ? 'SENSEX' : underlying === 'NIFTY' ? 'NIFTY50' : underlying === 'BANKNIFTY' ? 'BANKNIFTY' : null;
const spot = mktKey ? (mktMap[mktKey] || 0) : 0;
// Build price range: span all strikes ±15%
const strikes = opts.map(p => p._parsed.strike);
const minS = Math.min(...strikes), maxS = Math.max(...strikes);
const pad = Math.max((maxS - minS) * 0.6, minS * 0.08);
const lo = Math.floor((Math.min(minS, spot || minS) - pad) / 100) * 100;
const hi = Math.ceil((Math.max(maxS, spot || maxS) + pad) / 100) * 100;
const step = Math.max(Math.round((hi - lo) / 120 / 100) * 100, 50);
const xs = [], ys = [];
for (let s = lo; s <= hi; s += step) { xs.push(s); ys.push(calcPayoff(opts, s)); }
const maxY = Math.max(...ys), minY = Math.min(...ys);
// Breakevens: sign changes
const breakevens = [];
for (let i = 1; i < ys.length; i++) {
if ((ys[i-1] < 0 && ys[i] >= 0) || (ys[i-1] >= 0 && ys[i] < 0)) {
const be = xs[i-1] + (xs[i]-xs[i-1]) * (-ys[i-1]/(ys[i]-ys[i-1]));
breakevens.push(Math.round(be));
}
}
// Net premium (positive = credit, negative = debit)
const netPremium = opts.reduce((s, p) => s + p.avg_price * p.netqty, 0);
// Stats
const dte = opts[0]._parsed.expiry ? Math.max(0, Math.ceil((opts[0]._parsed.expiry - new Date()) / 86400000)) : '?';
const rr = minY !== 0 ? (maxY / Math.abs(minY)).toFixed(2) : '∞';
const setVal = (id, html, cls='') => {
const el = document.querySelector('#'+id+' .pfs-val');
el.innerHTML = html;
if (cls) el.className = 'pfs-val ' + cls;
};
setVal('pfs-credit', (netPremium>=0?'+':'')+'₹'+Math.round(netPremium).toLocaleString('en-IN'), netPremium>=0?'up':'dn');
setVal('pfs-maxp', maxY > 1e6 ? '∞' : '+₹'+Math.round(maxY).toLocaleString('en-IN'), 'up');
setVal('pfs-maxl', minY < -1e6 ? '' : ''+Math.round(minY).toLocaleString('en-IN'), 'dn');
setVal('pfs-rr', rr + 'x');
setVal('pfs-be', breakevens.length ? breakevens.map(b=>b.toLocaleString('en-IN')).join(', ') : 'None', '');
setVal('pfs-dte', dte + ' days');
document.getElementById('pf-underlying').textContent = underlying;
document.getElementById('pf-strategy').textContent = detectStrategy(opts);
// Build chart datasets: split into profit (green) and loss (red) segments
const dk = theme === 'dark';
const gc = dk ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.05)';
const tc = dk ? '#3D444D' : '#C4B8B0';
// Profit fill (above zero) and loss fill (below zero) — use two datasets with clip trick
const profitData = ys.map(y => ({ y: Math.max(y, 0) }));
const lossData = ys.map(y => ({ y: Math.min(y, 0) }));
if (payoffChartInst) payoffChartInst.destroy();
const annotations = {};
// Breakeven vertical lines
breakevens.forEach((be, i) => {
annotations['be'+i] = {
type: 'line', xMin: be, xMax: be,
borderColor: 'rgba(245,166,35,.7)', borderWidth: 1.5, borderDash: [5,4],
label: { display: true, content: be.toLocaleString('en-IN'), position: 'start',
color: '#F5A623', font: { size: 10, family: 'Geist Mono' }, yAdjust: -8 }
};
});
// Current spot
if (spot) {
annotations['spot'] = {
type: 'line', xMin: spot, xMax: spot,
borderColor: 'rgba(255,107,74,.8)', borderWidth: 2, borderDash: [6,3],
label: { display: true, content: spot.toLocaleString('en-IN'), position: 'end',
color: '#FF6B4A', font: { size: 10, family: 'Geist Mono' }, yAdjust: 8 }
};
}
payoffChartInst = new Chart(canvas, {
type: 'line',
data: {
labels: xs,
datasets: [
{
label: 'Profit',
data: ys,
borderColor: ys[Math.floor(ys.length/2)] >= 0 ? '#2ECC71' : '#E74C3C',
borderWidth: 2.5,
tension: 0,
pointRadius: 0,
fill: {
target: { value: 0 },
above: 'rgba(46,204,113,0.15)',
below: 'rgba(231,76,60,0.15)',
},
},
],
},
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: dk ? '#1C2330' : '#fff',
borderColor: dk ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
borderWidth: 1, titleColor: tc, bodyColor: dk ? '#E6EDF3' : '#1A1410',
padding: 10, cornerRadius: 8,
callbacks: {
title: items => 'Spot: ₹' + parseInt(items[0].label).toLocaleString('en-IN'),
label: item => {
const v = item.raw;
return ' P&L: ' + (v >= 0 ? '+' : '') + '₹' + Math.round(v).toLocaleString('en-IN');
},
},
},
annotation: (window.ChartAnnotation && {
annotations,
}),
},
scales: {
x: {
type: 'linear',
ticks: {
color: tc, maxTicksLimit: 10, font: { size: 9, family: 'Geist Mono' },
callback: v => v.toLocaleString('en-IN'),
},
grid: { color: gc },
},
y: {
ticks: {
color: tc, font: { size: 9, family: 'Geist Mono' },
callback: v => (v >= 0 ? '+' : '') + '₹' + Math.round(v).toLocaleString('en-IN'),
},
grid: { color: gc },
},
},
},
});
}
// End payoff graph
loadConfig();refresh(); loadConfig();refresh();
setInterval(refresh,60000); setInterval(refresh,60000);
setInterval(loadMarket,15000); setInterval(loadMarket,15000);