feat: payoff graph clean inject (Python, no template literal escaping)

This commit is contained in:
Manohar 2026-05-11 05:23:12 +00:00
parent c9a38be908
commit 882d55adad

View file

@ -8,7 +8,6 @@
<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">
<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>
: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)}
@ -133,17 +132,7 @@
.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>
.pf-stat{padding:10px 14px;border-right:1px solid var(--border);text-align:center}.pf-stat:last-child{border-right:none}.pfs-l{font-size:.6rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3);margin-bottom:4px}.pfs-v{font-family:'DM Serif Display',serif;font-size:1.05rem;color:var(--text)}</style>
</head>
<body>
<div class="wrap">
@ -206,33 +195,27 @@
<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>
<span id="pf-ul" style="font-size:.7rem;font-weight:600;color:var(--text2);font-family:Geist Mono,monospace"></span>
<span id="pf-strat" 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 style="display:grid;grid-template-columns:repeat(6,1fr);border-bottom:1px solid var(--border)">
<div class="pf-stat"><div class="pfs-l">NET PREMIUM</div><div class="pfs-v" id="pf-prem">&#8212;</div></div>
<div class="pf-stat"><div class="pfs-l">MAX PROFIT</div><div class="pfs-v up" id="pf-maxp">&#8212;</div></div>
<div class="pf-stat"><div class="pfs-l">MAX LOSS</div><div class="pfs-v dn" id="pf-maxl">&#8212;</div></div>
<div class="pf-stat"><div class="pfs-l">REWARD/RISK</div><div class="pfs-v" id="pf-rr">&#8212;</div></div>
<div class="pf-stat"><div class="pfs-l">BREAKEVEN(S)</div><div class="pfs-v" id="pf-be" style="font-size:.8rem">&#8212;</div></div>
<div class="pf-stat"><div class="pfs-l">DTE</div><div class="pfs-v" id="pf-dte">&#8212;</div></div>
</div>
<!-- Chart -->
<div style="padding:16px 16px 12px;height:260px;position:relative">
<div style="padding:14px 16px 10px;height:250px;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 id="pf-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 style="padding:0 16px 10px;font-size:.63rem;color:var(--text3)">Theoretical payoff at expiry based on avg cost. Does not account for time value or IV. Current spot shown as vertical line.</div>
</div>
</div>
<div class="card collapsible" id="sec-alrt" style="margin-bottom:16px">
@ -268,7 +251,6 @@
</div>
</div>
<script>
if(window.ChartAnnotation)Chart.register(window.ChartAnnotation);
let theme=localStorage.getItem('theme')||'dark';
document.documentElement.setAttribute('data-theme',theme);
document.getElementById('theme-btn').textContent=theme==='dark'?'\u2600\ufe0f':'\ud83c\udf19';
@ -276,7 +258,7 @@ function toggleTheme(){theme=theme==='dark'?'light':'dark';document.documentElem
const fmt=(n,d=0)=>n==null?'\u2014':(n>=0?'+\u20b9':'-\u20b9')+Math.abs(n).toLocaleString('en-IN',{maximumFractionDigits:d});
const cls=n=>n>50?'up':n<-50?'dn':'fl';
const toIST=s=>new Date(s+(s.includes('Z')?'':'Z')).toLocaleString('en-IN',{timeZone:'Asia/Kolkata',hour12:false,month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
function parseTTE(sym){const m=sym.match(/([A-Z]+)(\d{2})(\d{2})(\d{2})(\d+)(CE|PE)$/);if(!m)return null;const exp=new Date('20'+m[2]+'-'+m[4]+'-'+m[3]);const diff=Math.ceil((exp-new Date())/86400000);if(diff<0)return null;if(diff===0)return'\u26a1 Expires today';if(diff===1)return'\u26a0\ufe0f 1 day left';return diff+'d to expiry';}
function parseTTE(sym){const m=sym.match(/([A-Z]+)(\d{2})(\d{1})(\d{2})(\d+)(CE|PE)$/);if(!m)return null;const exp=new Date('20'+m[2]+'-'+m[3].padStart(2,'0')+'-'+m[4]);if(isNaN(exp.getTime()))return null;const diff=Math.ceil((exp-new Date())/86400000);if(diff<0)return null;if(diff===0)return'\u26a1 Expires today';if(diff===1)return'\u26a0\ufe0f 1 day left';return diff+'d to expiry';}
let chartInst=null,curH=8;
async function loadChart(h=8){
curH=h;['b2','b8','b24','b72'].forEach(id=>document.getElementById(id)?.classList.remove('on'));
@ -401,238 +383,215 @@ function toggleCard(id){
}catch(e){}
})();
// ── Payoff Graph ──────────────────────────────────────────────────────────────
let payoffChartInst = null;
/* ── Payoff Chart ─────────────────────────────────────────────────────── */
var payoffChartInst = null;
function parseOptionSym(sym) {
var RE1 = new RegExp("^([A-Z]+)(\\d{2})(\\d{1})(\\d{2})(\\d+)(CE|PE)$");
var m = sym.match(RE1);
if (m) {
var 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: isNaN(exp)?null:exp };
}
var RE2 = new RegExp("^([A-Z]+)(\\d{2})(\\d{2})(\\d{2})(\\d+)(CE|PE)$");
m = sym.match(RE2);
if (m) {
var exp2 = new Date(20+m[2]+-+m[3]+-+m[4]);
return { underlying: m[1], strike: parseInt(m[5]), type: m[6], expiry: isNaN(exp2)?null:exp2 };
}
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';
canvas._beLines = breakevens; canvas._spotLine = spot; canvas._chartXs = xs;
if (payoffChartInst) payoffChartInst.destroy();
payoffChartInst = new Chart(canvas, {
type: 'line',
data: { labels: xs, datasets: [
{label:'Profit Zone',data:ys.map(function(y){return y>=0?y:0;}),borderColor:'transparent',backgroundColor:'rgba(46,204,113,0.18)',tension:0,pointRadius:0,fill:true},
{label:'Loss Zone',data:ys.map(function(y){return y<0?y:0;}),borderColor:'transparent',backgroundColor:'rgba(231,76,60,0.18)',tension:0,pointRadius:0,fill:true},
{label:'P&L',data:ys,borderColor:'#FF6B4A',borderWidth:2.5,tension:0,pointRadius:0,fill:false},
] },
},
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');
},
},
},
},
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
var _payoffPlugin = {
// Register afterDraw plugin for vertical annotation lines
Chart.register({
id: 'payoffLines',
afterDraw: function(chart) {
if (!chart.canvas || !chart.canvas._beLines) return;
if (!chart.canvas._beLines) return;
var ctx = chart.ctx;
var xs = chart.canvas._chartXs;
if (!xs || !xs.length) return;
var xs = chart.canvas._chartXs || [];
if (!xs.length) return;
var xScale = chart.scales.x, yScale = chart.scales.y;
var lo = xs[0], hi = xs[xs.length-1], range = hi - lo;
if (!range) return;
function xPx(v) { return xScale.left + (v-lo)/range*(xScale.right-xScale.left); }
var lo = xs[0], hi = xs[xs.length-1], rng = hi - lo;
if (!rng) return;
function xPx(v) { return xScale.left + (v-lo)/rng*(xScale.right-xScale.left); }
ctx.save();
// Breakeven lines — amber dashed
(chart.canvas._beLines || []).forEach(function(be) {
var x = xPx(be);
if (x < xScale.left || x > xScale.right) return;
ctx.beginPath();
ctx.setLineDash([5,4]);
ctx.strokeStyle = 'rgba(245,166,35,0.85)';
ctx.lineWidth = 1.5;
ctx.moveTo(x, yScale.top);
ctx.lineTo(x, yScale.bottom);
ctx.stroke();
ctx.beginPath(); ctx.setLineDash([5,4]);
ctx.strokeStyle = 'rgba(245,166,35,0.85)'; ctx.lineWidth = 1.5;
ctx.moveTo(x, yScale.top); ctx.lineTo(x, yScale.bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#F5A623';
ctx.font = '9px monospace';
ctx.fillStyle = '#F5A623'; ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText(Number(be).toLocaleString('en-IN'), x, yScale.top + 10);
});
// Spot line — coral dashed
var spot = chart.canvas._spotLine;
if (spot) {
var x = xPx(spot);
var sp = chart.canvas._spotLine;
if (sp) {
var x = xPx(sp);
if (x >= xScale.left && x <= xScale.right) {
ctx.beginPath();
ctx.setLineDash([6,3]);
ctx.strokeStyle = 'rgba(255,107,74,0.9)';
ctx.lineWidth = 2;
ctx.moveTo(x, yScale.top);
ctx.lineTo(x, yScale.bottom);
ctx.stroke();
ctx.beginPath(); ctx.setLineDash([6,3]);
ctx.strokeStyle = 'rgba(255,107,74,0.9)'; ctx.lineWidth = 2;
ctx.moveTo(x, yScale.top); ctx.lineTo(x, yScale.bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#FF6B4A';
ctx.font = 'bold 9px monospace';
ctx.fillStyle = '#FF6B4A'; ctx.font = 'bold 9px monospace';
ctx.textAlign = 'center';
ctx.fillText('Spot ' + Number(spot).toLocaleString('en-IN'), x, yScale.bottom - 4);
ctx.fillText('Spot ' + Number(sp).toLocaleString('en-IN'), x, yScale.bottom - 4);
}
}
ctx.restore();
}
};
Chart.register(_payoffPlugin);
});
function parseOptionSym(sym) {
// Angel format: UNDERLYING + YY(2) + M(1) + DD(2) + STRIKE(N) + CE/PE
// e.g. SENSEX2651479000CE -> SENSEX, 26, 5, 14, 79000, CE
var RE1 = new RegExp('^([A-Z]+)(\\d{2})(\\d{1})(\\d{2})(\\d+)(CE|PE)$');
var m = sym.match(RE1);
if (m) {
var 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:isNaN(exp)?null:exp };
}
// Fallback 2-digit month (Oct+)
var RE2 = new RegExp('^([A-Z]+)(\\d{2})(\\d{2})(\\d{2})(\\d+)(CE|PE)$');
m = sym.match(RE2);
if (m) {
var exp2 = new Date('20'+m[2]+'-'+m[3]+'-'+m[4]);
return { underlying:m[1], strike:parseInt(m[5]), type:m[6], expiry:isNaN(exp2)?null:exp2 };
}
return null;
}
function calcPayoff(opts, spot) {
var total = 0;
opts.forEach(function(p) {
var info = p._parsed;
if (!info) return;
var intrinsic = info.type === 'CE' ? Math.max(spot - info.strike, 0) : Math.max(info.strike - spot, 0);
total += (intrinsic - (+p.avg_price)) * (+p.netqty);
});
return total;
}
function detectStrategy(opts) {
var calls = opts.filter(function(p){return p._parsed.type==='CE';});
var puts = opts.filter(function(p){return p._parsed.type==='PE';});
var longs = opts.filter(function(p){return (+p.netqty)>0;});
var shorts= opts.filter(function(p){return (+p.netqty)<0;});
if (opts.length===1) return (longs.length?'Long ':'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===opts.length) return 'Short Strangle/Straddle';
if (calls.length>=1&&puts.length>=1&&longs.length===opts.length) return 'Long Strangle/Straddle';
if (opts.length===4) return 'Iron Condor/Butterfly';
return opts.length+'-Leg Strategy';
}
async function loadPayoff() {
var canvas = document.getElementById('payoff-chart');
var emptyEl = document.getElementById('pf-empty');
if (!canvas || !emptyEl) return;
try {
var posRes = await fetch('/api/positions').then(function(r){return r.json();}).catch(function(){return {data:[]};});
var mktRes = await fetch('/api/market').then(function(r){return r.json();}).catch(function(){return {data:[]};});
var open = (posRes.data || []).filter(function(p){return p.is_closed===0;});
var opts = open.filter(function(p){return /CE|PE/.test(p.tradingsymbol);}).map(function(p){
p._parsed = parseOptionSym(p.tradingsymbol);
return p;
}).filter(function(p){return p._parsed !== null;});
if (!opts.length) {
canvas.style.display = 'none'; emptyEl.style.display = 'flex'; return;
}
canvas.style.display = 'block'; emptyEl.style.display = 'none';
// Spot price from market data
var mktMap = {};
(mktRes.data || []).forEach(function(q){ mktMap[q.key] = q.price; });
var ul = opts[0]._parsed.underlying;
var mktKey = ul==='SENSEX'?'SENSEX': ul==='NIFTY'?'NIFTY50': ul==='BANKNIFTY'?'BANKNIFTY': null;
var spot = mktKey ? (mktMap[mktKey]||0) : 0;
// Price range
var strikes = opts.map(function(p){return p._parsed.strike;});
var minS = Math.min.apply(null,strikes), maxS = Math.max.apply(null,strikes);
var pad = Math.max((maxS-minS)*0.6, minS*0.08);
var lo = Math.floor((Math.min(minS, spot||minS)-pad)/100)*100;
var hi = Math.ceil((Math.max(maxS, spot||maxS)+pad)/100)*100;
var step = Math.max(Math.round((hi-lo)/100/100)*100, 50);
var xs=[], ys=[];
for (var s=lo; s<=hi; s+=step){ xs.push(s); ys.push(calcPayoff(opts,s)); }
var maxY = Math.max.apply(null,ys), minY = Math.min.apply(null,ys);
// Breakevens
var bes = [];
for (var i=1; i<ys.length; i++) {
if ((ys[i-1]<0&&ys[i]>=0)||(ys[i-1]>=0&&ys[i]<0)) {
bes.push(Math.round(xs[i-1]+(xs[i]-xs[i-1])*(-ys[i-1]/(ys[i]-ys[i-1]))));
}
}
var netPrem = opts.reduce(function(s,p){return s+(+p.avg_price)*(+p.netqty);},0);
var dte = opts[0]._parsed.expiry ? Math.max(0,Math.ceil((opts[0]._parsed.expiry-new Date())/86400000)) : '?';
var rr = minY!==0 ? (maxY/Math.abs(minY)).toFixed(2) : '∞';
function sv(id, txt, cls) {
var e = document.getElementById(id);
if (!e) return;
e.textContent = txt;
if (cls) e.className = 'pfs-v '+cls;
}
var pr = Math.round(netPrem).toLocaleString('en-IN');
sv('pf-prem', (netPrem>=0?'+':'')+'\u20b9'+pr, netPrem>=0?'up':'dn');
sv('pf-maxp', maxY>1e7?'\u221e':'+\u20b9'+Math.round(maxY).toLocaleString('en-IN'), 'up');
sv('pf-maxl', minY<-1e7?'\u2212\u221e':'\u20b9'+Math.round(minY).toLocaleString('en-IN'), 'dn');
sv('pf-rr', rr+'x');
sv('pf-be', bes.length ? bes.map(function(b){return b.toLocaleString('en-IN');}).join(', ') : 'None');
sv('pf-dte', dte+' days');
var ulEl = document.getElementById('pf-ul');
var stEl = document.getElementById('pf-strat');
if (ulEl) ulEl.textContent = ul;
if (stEl) stEl.textContent = detectStrategy(opts);
var dk = theme==='dark';
var gc = dk?'rgba(255,255,255,.05)':'rgba(0,0,0,.05)';
var tc = dk?'#3D444D':'#C4B8B0';
if (payoffChartInst) payoffChartInst.destroy();
canvas._beLines = bes; canvas._spotLine = spot; canvas._chartXs = xs;
payoffChartInst = new Chart(canvas, {
type: 'line',
data: {
labels: xs,
datasets: [
{ label:'Profit Zone', data:ys.map(function(y){return y>=0?y:0;}), borderColor:'transparent', backgroundColor:'rgba(46,204,113,0.18)', tension:0, pointRadius:0, fill:true },
{ label:'Loss Zone', data:ys.map(function(y){return y<0?y:0;}), borderColor:'transparent', backgroundColor:'rgba(231,76,60,0.18)', tension:0, pointRadius:0, fill:true },
{ label:'P&L', data:ys, borderColor:'#FF6B4A', borderWidth:2.5, tension:0, pointRadius:0, fill:false }
]
},
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:function(items){return 'Spot: \u20b9'+parseInt(items[0].label).toLocaleString('en-IN');},
label:function(item){
if (item.datasetIndex!==2) return null;
var v=item.raw;
return ' P&L: '+(v>=0?'+':'')+'\u20b9'+Math.round(v).toLocaleString('en-IN');
}
}
}
},
scales: {
x:{type:'linear', ticks:{color:tc,maxTicksLimit:10,font:{size:9,family:'monospace'},callback:function(v){return v.toLocaleString('en-IN');}}, grid:{color:gc}},
y:{ticks:{color:tc,font:{size:9,family:'monospace'},callback:function(v){return (v>=0?'+':'')+'\u20b9'+Math.round(v).toLocaleString('en-IN');}}, grid:{color:gc}}
}
}
});
} catch(err) {
console.error('loadPayoff error:', err);
}
}
/* ── End Payoff ───────────────────────────────────────────────────────── */
loadConfig();refresh();
setInterval(refresh,60000);