feat: payoff graph clean inject (Python, no template literal escaping)
This commit is contained in:
parent
c9a38be908
commit
882d55adad
1 changed files with 202 additions and 243 deletions
|
|
@ -8,7 +8,6 @@
|
||||||
<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)}
|
||||||
|
|
@ -133,17 +132,7 @@
|
||||||
.pcard.pt .pval { font-size: 1.7rem; }
|
.pcard.pt .pval { font-size: 1.7rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
.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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
@ -206,33 +195,27 @@
|
||||||
<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>
|
||||||
<div style="display:flex;align-items:center;gap:8px">
|
<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-ul" 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-strat" style="font-size:.68rem;padding:2px 8px;border-radius:9999px;background:var(--bg3);border:1px solid var(--border2);color:var(--text2)"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="collapse-arrow">▼</span>
|
<span class="collapse-arrow">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse-body">
|
<div class="collapse-body">
|
||||||
<!-- Stats row -->
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);border-bottom:1px solid var(--border)">
|
||||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0;border-bottom:1px solid var(--border)">
|
<div class="pf-stat"><div class="pfs-l">NET PREMIUM</div><div class="pfs-v" id="pf-prem">—</div></div>
|
||||||
<div class="pf-stat" id="pfs-credit"><div class="pfs-lbl">NET PREMIUM</div><div class="pfs-val">—</div></div>
|
<div class="pf-stat"><div class="pfs-l">MAX PROFIT</div><div class="pfs-v up" id="pf-maxp">—</div></div>
|
||||||
<div class="pf-stat" id="pfs-maxp"><div class="pfs-lbl">MAX PROFIT</div><div class="pfs-val up">—</div></div>
|
<div class="pf-stat"><div class="pfs-l">MAX LOSS</div><div class="pfs-v dn" id="pf-maxl">—</div></div>
|
||||||
<div class="pf-stat" id="pfs-maxl"><div class="pfs-lbl">MAX LOSS</div><div class="pfs-val dn">—</div></div>
|
<div class="pf-stat"><div class="pfs-l">REWARD/RISK</div><div class="pfs-v" id="pf-rr">—</div></div>
|
||||||
<div class="pf-stat" id="pfs-rr"><div class="pfs-lbl">REWARD/RISK</div><div class="pfs-val">—</div></div>
|
<div class="pf-stat"><div class="pfs-l">BREAKEVEN(S)</div><div class="pfs-v" id="pf-be" style="font-size:.8rem">—</div></div>
|
||||||
<div class="pf-stat" id="pfs-be"><div class="pfs-lbl">BREAKEVEN(S)</div><div class="pfs-val" style="font-size:.78rem">—</div></div>
|
<div class="pf-stat"><div class="pfs-l">DTE</div><div class="pfs-v" id="pf-dte">—</div></div>
|
||||||
<div class="pf-stat" id="pfs-dte"><div class="pfs-lbl">DTE</div><div class="pfs-val">—</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Chart -->
|
<div style="padding:14px 16px 10px;height:250px;position:relative">
|
||||||
<div style="padding:16px 16px 12px;height:260px;position:relative">
|
|
||||||
<canvas id="payoff-chart"></canvas>
|
<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">
|
<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">📈</span>
|
<span style="font-size:1.4rem">📈</span><span>No option positions to analyse</span>
|
||||||
<span>No option positions to analyse</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 16px 12px;font-size:.65rem;color:var(--text3)">
|
<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>
|
||||||
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>
|
</div>
|
||||||
<div class="card collapsible" id="sec-alrt" style="margin-bottom:16px">
|
<div class="card collapsible" id="sec-alrt" style="margin-bottom:16px">
|
||||||
|
|
@ -268,7 +251,6 @@
|
||||||
</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';
|
||||||
|
|
@ -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 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 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'});
|
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;
|
let chartInst=null,curH=8;
|
||||||
async function loadChart(h=8){
|
async function loadChart(h=8){
|
||||||
curH=h;['b2','b8','b24','b72'].forEach(id=>document.getElementById(id)?.classList.remove('on'));
|
curH=h;['b2','b8','b24','b72'].forEach(id=>document.getElementById(id)?.classList.remove('on'));
|
||||||
|
|
@ -401,238 +383,215 @@ function toggleCard(id){
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Payoff Graph ──────────────────────────────────────────────────────────────
|
/* ── Payoff Chart ─────────────────────────────────────────────────────── */
|
||||||
let payoffChartInst = null;
|
var payoffChartInst = null;
|
||||||
|
|
||||||
function parseOptionSym(sym) {
|
// Register afterDraw plugin for vertical annotation lines
|
||||||
var RE1 = new RegExp("^([A-Z]+)(\\d{2})(\\d{1})(\\d{2})(\\d+)(CE|PE)$");
|
Chart.register({
|
||||||
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 = {
|
|
||||||
id: 'payoffLines',
|
id: 'payoffLines',
|
||||||
afterDraw: function(chart) {
|
afterDraw: function(chart) {
|
||||||
if (!chart.canvas || !chart.canvas._beLines) return;
|
if (!chart.canvas._beLines) return;
|
||||||
var ctx = chart.ctx;
|
var ctx = chart.ctx;
|
||||||
var xs = chart.canvas._chartXs;
|
var xs = chart.canvas._chartXs || [];
|
||||||
if (!xs || !xs.length) return;
|
if (!xs.length) return;
|
||||||
var xScale = chart.scales.x, yScale = chart.scales.y;
|
var xScale = chart.scales.x, yScale = chart.scales.y;
|
||||||
var lo = xs[0], hi = xs[xs.length-1], range = hi - lo;
|
var lo = xs[0], hi = xs[xs.length-1], rng = hi - lo;
|
||||||
if (!range) return;
|
if (!rng) return;
|
||||||
function xPx(v) { return xScale.left + (v-lo)/range*(xScale.right-xScale.left); }
|
function xPx(v) { return xScale.left + (v-lo)/rng*(xScale.right-xScale.left); }
|
||||||
ctx.save();
|
ctx.save();
|
||||||
// Breakeven lines — amber dashed
|
// Breakeven lines — amber dashed
|
||||||
(chart.canvas._beLines || []).forEach(function(be) {
|
(chart.canvas._beLines || []).forEach(function(be) {
|
||||||
var x = xPx(be);
|
var x = xPx(be);
|
||||||
if (x < xScale.left || x > xScale.right) return;
|
if (x < xScale.left || x > xScale.right) return;
|
||||||
ctx.beginPath();
|
ctx.beginPath(); ctx.setLineDash([5,4]);
|
||||||
ctx.setLineDash([5,4]);
|
ctx.strokeStyle = 'rgba(245,166,35,0.85)'; ctx.lineWidth = 1.5;
|
||||||
ctx.strokeStyle = 'rgba(245,166,35,0.85)';
|
ctx.moveTo(x, yScale.top); ctx.lineTo(x, yScale.bottom); ctx.stroke();
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.moveTo(x, yScale.top);
|
|
||||||
ctx.lineTo(x, yScale.bottom);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.fillStyle = '#F5A623';
|
ctx.fillStyle = '#F5A623'; ctx.font = '9px monospace';
|
||||||
ctx.font = '9px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(Number(be).toLocaleString('en-IN'), x, yScale.top + 10);
|
ctx.fillText(Number(be).toLocaleString('en-IN'), x, yScale.top + 10);
|
||||||
});
|
});
|
||||||
// Spot line — coral dashed
|
// Spot line — coral dashed
|
||||||
var spot = chart.canvas._spotLine;
|
var sp = chart.canvas._spotLine;
|
||||||
if (spot) {
|
if (sp) {
|
||||||
var x = xPx(spot);
|
var x = xPx(sp);
|
||||||
if (x >= xScale.left && x <= xScale.right) {
|
if (x >= xScale.left && x <= xScale.right) {
|
||||||
ctx.beginPath();
|
ctx.beginPath(); ctx.setLineDash([6,3]);
|
||||||
ctx.setLineDash([6,3]);
|
ctx.strokeStyle = 'rgba(255,107,74,0.9)'; ctx.lineWidth = 2;
|
||||||
ctx.strokeStyle = 'rgba(255,107,74,0.9)';
|
ctx.moveTo(x, yScale.top); ctx.lineTo(x, yScale.bottom); ctx.stroke();
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.moveTo(x, yScale.top);
|
|
||||||
ctx.lineTo(x, yScale.bottom);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.fillStyle = '#FF6B4A';
|
ctx.fillStyle = '#FF6B4A'; ctx.font = 'bold 9px monospace';
|
||||||
ctx.font = 'bold 9px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
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();
|
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();
|
loadConfig();refresh();
|
||||||
setInterval(refresh,60000);
|
setInterval(refresh,60000);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue