feat: add Analysis tab with position intelligence engine
Phase 1 of the position-tracker → analysis port: Backend: - src/ai/types.ts — shared types (AnalysisInput, ClassifiedPosition, AnalysisResult, etc.) - src/ai/greeks.ts — added calcTheoreticalPrice() export for scenario analysis - src/ai/classifier.ts — pure-function position classifier (DEAD_WEIGHT/WORKING/UNDER_PRESSURE/HEDGE/DANGER_SHORT) with module-level hysteresis Map - src/ai/portfolio-greeks.ts — aggregates per-unit Greeks × quantity into portfolio totals - src/ai/risk-assessor.ts — naked short detection, concentration, side imbalance, Greeks breach checks - src/ai/scenario.ts — P&L grid at ±100..±1000 spot moves per underlying - src/ai/analyze.ts — orchestrator: maps Position[] → AnalysisInput[], reads spot from market_cache, stores JSON in analysis_snapshots - src/db/client.ts — analysis_snapshots table + index - src/tracker/poll.ts — runAnalysis() called after every pollTick + forcePoll - src/api/server.ts — GET /api/analysis endpoint Frontend (public/index.html): - Multi-page nav with Dashboard | Analysis tabs (showView()) - Analysis view: summary cards (delta/theta/vega/risk level), action list (urgent positions sorted by urgency score), classification table with urgency bars, risk assessment details, scenario P&L grid per underlying - loadAnalysis() + renderAnalysis() wired to tab switch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb8a00c196
commit
dca5fea679
11 changed files with 1045 additions and 3 deletions
|
|
@ -136,6 +136,11 @@
|
|||
.exp-tab{padding:2px 9px;border-radius:7px;font-size:.65rem;font-weight:600;border:1px solid var(--border2);cursor:pointer;background:transparent;color:var(--text2);transition:all .12s;font-family:'Geist Mono',monospace;}
|
||||
.exp-tab:hover{border-color:var(--coral);color:var(--text)}
|
||||
.exp-tab.active{background:var(--coral);border-color:var(--coral);color:#fff}
|
||||
.nav-tabs{display:flex;gap:3px}
|
||||
.nav-tab{padding:5px 14px;border-radius:9px;font-size:.72rem;font-weight:600;border:1px solid var(--border2);cursor:pointer;background:transparent;color:var(--text2);transition:all .12s;font-family:'Geist',sans-serif}
|
||||
.nav-tab:hover{color:var(--text);border-color:var(--coral)}
|
||||
.nav-tab.active{background:var(--coral);border-color:var(--coral);color:#fff}
|
||||
@media(max-width:768px){.a-sum-grid{grid-template-columns:1fr 1fr!important}}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -145,15 +150,20 @@
|
|||
<div class="nav-l">
|
||||
<span class="logo">Angel</span>
|
||||
<div class="nav-sep"></div>
|
||||
<span class="nav-sub">Position Tracker</span>
|
||||
<div class="nav-tabs">
|
||||
<button class="nav-tab active" data-target="dashboard" onclick="showView('dashboard')">Dashboard</button>
|
||||
<button class="nav-tab" data-target="analysis" onclick="showView('analysis')">Analysis</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-r">
|
||||
<span class="ts" id="ts">—</span>
|
||||
<span id="s-err" style="display:none;font-size:.65rem;font-family:'Geist Mono',monospace"></span>
|
||||
<div class="pill" id="mkt-pill"><span class="dot"></span><span id="mkt-lbl">Checking</span></div>
|
||||
<button class="ibtn" id="theme-btn" onclick="toggleTheme()">☀️</button>
|
||||
<button class="ibtn" onclick="refresh()">↻</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div data-view="dashboard" id="view-dashboard">
|
||||
<div class="g3">
|
||||
<div class="card pcard pu"><div class="plbl">Unrealised P&L</div><div class="pval fl" id="v-u">—</div><div class="psub">Open positions MTM</div></div>
|
||||
<div class="card pcard pr"><div class="plbl">Realised P&L</div><div class="pval fl" id="v-r">—</div><div class="psub">Partial + full exits today</div></div>
|
||||
|
|
@ -290,7 +300,46 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end view-dashboard -->
|
||||
<div data-view="analysis" id="view-analysis" style="display:none">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px" class="a-sum-grid">
|
||||
<div class="card pcard pu"><div class="plbl">Net Delta</div><div class="pval fl" id="a-delta">—</div><div class="psub">Directional exposure</div></div>
|
||||
<div class="card pcard" style="--ca:#F5A623;--cb:#E67E22"><div class="plbl">Net Theta / day</div><div class="pval fl" id="a-theta">—</div><div class="psub">Daily time decay (₹)</div></div>
|
||||
<div class="card pcard pr"><div class="plbl">Net Vega</div><div class="pval fl" id="a-vega">—</div><div class="psub">Vol sensitivity</div></div>
|
||||
<div class="card pcard pt"><div class="plbl">Risk Level</div><div class="pval fl" id="a-risk-lvl">—</div><div class="psub" id="a-risk-sub">—</div></div>
|
||||
</div>
|
||||
<div class="card collapsible" id="a-sec-class" style="margin-bottom:16px">
|
||||
<div class="card-head" onclick="toggleCard('a-sec-class')">
|
||||
<span class="card-title">Position Intelligence</span>
|
||||
<div id="a-class-badges" style="display:flex;gap:5px;flex-wrap:wrap"></div>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="collapse-body">
|
||||
<div id="a-action-list" style="padding:10px 18px 0;display:none"></div>
|
||||
<table>
|
||||
<thead><tr><th>Symbol</th><th>Category</th><th>DTE</th><th>IV%</th><th>PosΔ</th><th>Θ/day</th><th>Unrealised</th><th>Urgency</th></tr></thead>
|
||||
<tbody id="a-class-body"><tr><td colspan="8" style="text-align:center;padding:36px;color:var(--text3)">Waiting for first poll with market data…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card collapsible" id="a-sec-risk" style="margin-bottom:16px">
|
||||
<div class="card-head" onclick="toggleCard('a-sec-risk')"><span class="card-title">Risk Assessment</span><span class="collapse-arrow">▼</span></div>
|
||||
<div class="collapse-body"><div id="a-risk-body" style="padding:14px 20px;color:var(--text3);font-size:.8rem">Loading…</div></div>
|
||||
</div>
|
||||
<div class="card collapsible" id="a-sec-scen" style="margin-bottom:16px">
|
||||
<div class="card-head" onclick="toggleCard('a-sec-scen')">
|
||||
<span class="card-title">Scenario Analysis</span>
|
||||
<span style="font-size:.68rem;color:var(--text3)">P&L at SENSEX spot moves (fixed vol)</span>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="collapse-body"><div id="a-scen-body" style="padding:14px 20px;color:var(--text3);font-size:.8rem">Loading…</div></div>
|
||||
</div>
|
||||
<div style="font-size:.62rem;color:var(--text3);text-align:center;padding-bottom:20px">
|
||||
Greeks from Black-Scholes with Newton-Raphson IV solver. Scenarios hold vol surface fixed.
|
||||
<span id="a-asof" style="margin-left:8px"></span>
|
||||
</div>
|
||||
</div><!-- end view-analysis -->
|
||||
</div><!-- end .wrap -->
|
||||
<script>
|
||||
let theme=localStorage.getItem('theme')||'dark';
|
||||
document.documentElement.setAttribute('data-theme',theme);
|
||||
|
|
@ -765,6 +814,128 @@ function setExpiry(btn,exp){
|
|||
loadPositions();
|
||||
loadPayoff();
|
||||
}
|
||||
/* ── Analysis Tab ─────────────────────────────────────────────────────── */
|
||||
var _CAT_COLOR={DANGER_SHORT:'#E74C3C',DEAD_WEIGHT:'#F5A623',UNDER_PRESSURE:'#E67E22',HEDGE:'#3498DB',WORKING:'#2ECC71'};
|
||||
var _CAT_EMOJI={DANGER_SHORT:'🔴',DEAD_WEIGHT:'🟡',UNDER_PRESSURE:'🟠',HEDGE:'🔵',WORKING:'🟢'};
|
||||
var _RISK_COL={LOW:'var(--green)',MODERATE:'var(--amber)',HIGH:'#E67E22',CRITICAL:'var(--red)'};
|
||||
|
||||
function showView(name){
|
||||
document.querySelectorAll('[data-view]').forEach(function(el){el.style.display='none';});
|
||||
var el=document.getElementById('view-'+name);if(el)el.style.display='block';
|
||||
document.querySelectorAll('.nav-tab').forEach(function(t){t.classList.remove('active');});
|
||||
var tab=document.querySelector('.nav-tab[data-target="'+name+'"]');if(tab)tab.classList.add('active');
|
||||
if(name==='analysis')loadAnalysis();
|
||||
}
|
||||
|
||||
async function loadAnalysis(){
|
||||
try{
|
||||
var res=await fetch('/api/analysis').then(function(r){return r.json();});
|
||||
if(!res.ok||!res.data)return;
|
||||
renderAnalysis(res.data,res.asOf);
|
||||
}catch(e){console.error('loadAnalysis:',e);}
|
||||
}
|
||||
|
||||
function renderAnalysis(d,asOf){
|
||||
var cl=d.classified||[],gk=d.greeks||{},rk=d.risk||{},sc=d.scenarios||[];
|
||||
var se=function(id,v,c){var e=document.getElementById(id);if(!e)return;e.textContent=v;if(c)e.className='pval '+c;};
|
||||
se('a-delta',(gk.totalDelta||0).toFixed(1),gk.totalDelta>50?'up':gk.totalDelta<-50?'dn':'fl');
|
||||
se('a-theta','₹'+(gk.totalTheta||0).toFixed(0),gk.totalTheta>0?'up':'dn');
|
||||
se('a-vega',(gk.totalVega||0).toFixed(0),'fl');
|
||||
se('a-risk-lvl',rk.riskLevel||'—',rk.riskLevel==='LOW'?'up':rk.riskLevel==='CRITICAL'?'dn':'fl');
|
||||
var sub=document.getElementById('a-risk-sub');
|
||||
if(sub)sub.textContent=((rk.breachedRules||[]).length)+' rule'+(((rk.breachedRules||[]).length)!==1?'s':'')+' breached';
|
||||
|
||||
var badges=document.getElementById('a-class-badges');
|
||||
if(badges){
|
||||
var cts={};cl.forEach(function(p){cts[p.category]=(cts[p.category]||0)+1;});
|
||||
badges.innerHTML=Object.entries(cts).map(function(kv){
|
||||
var col=_CAT_COLOR[kv[0]]||'#8B949E';
|
||||
return '<span style="background:'+col+'22;color:'+col+';padding:2px 9px;border-radius:9999px;font-size:.65rem;font-weight:600">'+
|
||||
(_CAT_EMOJI[kv[0]]||'')+' '+kv[0].replace(/_/g,' ')+' \xd7'+kv[1]+'</span>';}).join('');}
|
||||
|
||||
var urgent=cl.filter(function(p){return p.urgencyScore>0;}).sort(function(a,b){return b.urgencyScore-a.urgencyScore;});
|
||||
var aList=document.getElementById('a-action-list');
|
||||
if(aList){
|
||||
if(urgent.length>0){
|
||||
aList.style.display='block';
|
||||
aList.innerHTML='<div style="font-size:.67rem;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--text2);margin-bottom:8px">Action Required</div>'+
|
||||
urgent.map(function(p){
|
||||
var col=_CAT_COLOR[p.category]||'#8B949E';var em=_CAT_EMOJI[p.category]||'';
|
||||
var msg=p.category==='DANGER_SHORT'?'ITM short with thin time value — closing avoids assignment risk':
|
||||
p.category==='DEAD_WEIGHT'?'Far OTM long bleeding theta with near-zero value — roll or close':
|
||||
p.category==='UNDER_PRESSURE'?'Down >50% of premium — monitor and consider stop':'';
|
||||
return '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;padding:8px 12px;background:'+col+'11;border:1px solid '+col+'33;border-radius:10px">'+
|
||||
'<span style="font-size:.85rem">'+em+'</span>'+
|
||||
'<span style="font-size:.78rem;font-weight:600;color:var(--text);min-width:180px">'+p.symbol+'</span>'+
|
||||
'<span style="font-size:.68rem;color:var(--text2);flex:1">'+msg+'</span>'+
|
||||
'<span style="font-size:.65rem;font-family:\'Geist Mono\',monospace;color:var(--text2)">'+p.daysToExpiry.toFixed(0)+'d \xb7 IV '+p.ivPct.toFixed(0)+'%</span>'+
|
||||
'<span style="background:'+col+';color:#fff;padding:1px 8px;border-radius:9999px;font-size:.65rem;font-weight:700">'+p.urgencyScore+'</span></div>';
|
||||
}).join('');
|
||||
}else{aList.style.display='none';}
|
||||
}
|
||||
|
||||
var tbody=document.getElementById('a-class-body');
|
||||
if(tbody){
|
||||
if(!cl.length){tbody.innerHTML='<tr><td colspan="8" style="text-align:center;padding:36px;color:var(--text3)">No option positions to analyse</td></tr>';}
|
||||
else{
|
||||
tbody.innerHTML=cl.map(function(p){
|
||||
var col=_CAT_COLOR[p.category]||'#8B949E';
|
||||
var urg=p.urgencyScore>0
|
||||
?'<div style="width:'+Math.min(p.urgencyScore,100)+'%;background:'+col+';height:4px;border-radius:2px;margin-bottom:2px"></div><span style="font-size:.6rem;color:var(--text3)">'+p.urgencyScore+'</span>'
|
||||
:'<span style="font-size:.6rem;color:var(--text3)">—</span>';
|
||||
return '<tr>'+
|
||||
'<td><div class="sym">'+p.symbol+'</div><div class="sym-meta">'+p.underlying+' \xb7 '+p.optionType+' '+p.strikePrice.toLocaleString('en-IN')+'</div></td>'+
|
||||
'<td style="text-align:center"><span style="background:'+col+'22;color:'+col+';padding:2px 8px;border-radius:9999px;font-size:.62rem;font-weight:600">'+(_CAT_EMOJI[p.category]||'')+' '+p.category.replace(/_/g,' ')+'</span></td>'+
|
||||
'<td class="mono">'+(p.daysToExpiry<1?'<span style="color:var(--red)">'+p.daysToExpiry.toFixed(1)+'</span>':p.daysToExpiry.toFixed(1))+'d</td>'+
|
||||
'<td class="mono">'+p.ivPct.toFixed(1)+'%</td>'+
|
||||
'<td class="mono '+(p.positionDelta>0.1?'up':p.positionDelta<-0.1?'dn':'fl')+'">'+p.positionDelta.toFixed(2)+'</td>'+
|
||||
'<td class="mono '+(p.positionTheta>0?'up':p.positionTheta<0?'dn':'fl')+'">'+p.positionTheta.toFixed(0)+'</td>'+
|
||||
'<td class="mono '+(p.unrealizedPnl>=0?'up':'dn')+'">'+fmt(p.unrealizedPnl)+'</td>'+
|
||||
'<td>'+urg+'</td></tr>';}).join('');}}
|
||||
|
||||
var rBody=document.getElementById('a-risk-body');
|
||||
if(rBody){
|
||||
var rl=rk.riskLevel||'LOW';var rlc=_RISK_COL[rl]||'var(--green)';
|
||||
var html='<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">'+
|
||||
'<span style="font-size:1.3rem;font-weight:700;font-family:\'DM Serif Display\',serif;color:'+rlc+'">'+rl+'</span>'+
|
||||
'<span style="font-size:.72rem;color:var(--text2)">'+(rk.breachedRules||[]).length+' rule(s) breached</span></div>';
|
||||
if((rk.breachedRules||[]).length){
|
||||
html+='<div style="margin-bottom:10px">';
|
||||
(rk.breachedRules||[]).forEach(function(r){html+='<div style="font-size:.75rem;color:var(--text);margin-bottom:4px">⚠️ '+r+'</div>';});
|
||||
html+='</div>';}
|
||||
if(rk.hasNakedShorts){
|
||||
html+='<div style="margin-bottom:8px;padding:8px 12px;background:rgba(231,76,60,.1);border:1px solid rgba(231,76,60,.3);border-radius:10px;font-size:.75rem">'+
|
||||
'<span style="color:var(--red);font-weight:600">⚠ Naked Shorts</span>: '+
|
||||
(rk.nakedShortLegs||[]).map(function(l){return l.symbol+' (qty '+l.quantity+')';}).join(', ')+'</div>';}
|
||||
if(rk.concentrationWarning){html+='<div style="margin-bottom:6px;font-size:.75rem;color:var(--amber)">⚠ '+rk.concentrationWarning+'</div>';}
|
||||
html+='<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:10px">'+
|
||||
'<div style="font-size:.72rem;color:var(--text2)">Short CE: <span style="color:var(--text)">₹'+Math.round(rk.netShortCEExposure||0).toLocaleString('en-IN')+'</span></div>'+
|
||||
'<div style="font-size:.72rem;color:var(--text2)">Short PE: <span style="color:var(--text)">₹'+Math.round(rk.netShortPEExposure||0).toLocaleString('en-IN')+'</span></div>'+
|
||||
'<div style="font-size:.72rem;color:var(--text2)">Max Theo Loss: <span style="color:var(--red)">₹'+Math.round(rk.maxTheoreticalLoss||0).toLocaleString('en-IN')+'</span></div></div>';
|
||||
rBody.innerHTML=html;}
|
||||
|
||||
var sBody=document.getElementById('a-scen-body');
|
||||
if(sBody){
|
||||
if(!sc.length){sBody.innerHTML='<div style="color:var(--text3);font-size:.8rem">No option positions yet</div>';}
|
||||
else{
|
||||
sBody.innerHTML=sc.map(function(res){
|
||||
return '<div style="margin-bottom:14px">'+
|
||||
'<div style="font-size:.68rem;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--text2);margin-bottom:8px">'+
|
||||
res.underlying+' — spot ₹'+Math.round(res.currentSpot).toLocaleString('en-IN')+'</div>'+
|
||||
'<div style="overflow-x:auto"><table style="min-width:500px"><thead><tr>'+
|
||||
res.scenarios.map(function(pt){
|
||||
var c=pt.spotMove===0?'var(--text2)':pt.spotMove>0?'var(--green)':'var(--red)';
|
||||
return '<th style="color:'+c+'">'+(pt.spotMove>0?'+':'')+pt.spotMove+'</th>';}).join('')+
|
||||
'</tr></thead><tbody><tr>'+
|
||||
res.scenarios.map(function(pt){
|
||||
var c=pt.totalPnl>0?'var(--green)':pt.totalPnl<0?'var(--red)':'var(--text2)';
|
||||
return '<td class="mono" style="color:'+c+';font-weight:600">'+(pt.totalPnl>=0?'+':'')+'₹'+Math.round(pt.totalPnl).toLocaleString('en-IN')+'</td>';}).join('')+
|
||||
'</tr></tbody></table></div></div>';}).join('');}}
|
||||
|
||||
var asofEl=document.getElementById('a-asof');
|
||||
if(asofEl&&asOf)asofEl.textContent='Updated: '+toIST(asOf);
|
||||
}
|
||||
/* ── End Analysis Tab ─────────────────────────────────────────────────── */
|
||||
loadConfig();refresh();
|
||||
setInterval(refresh,60000);
|
||||
setInterval(loadMarket,15000);
|
||||
|
|
|
|||
97
src/ai/analyze.ts
Normal file
97
src/ai/analyze.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { db } from '../db/client.js';
|
||||
import type { Position } from '../angel/types.js';
|
||||
import { parseOptionSymbol, dteFromExpiry } from './greeks.js';
|
||||
import { classifyPositions } from './classifier.js';
|
||||
import { computePortfolioGreeks } from './portfolio-greeks.js';
|
||||
import { assessPortfolioRisk } from './risk-assessor.js';
|
||||
import { runScenarioAnalysis } from './scenario.js';
|
||||
import type { AnalysisInput, AnalysisResult, PortfolioGreeks, RiskAssessment } from './types.js';
|
||||
|
||||
const LOT_SIZES: Record<string, number> = {
|
||||
SENSEX: 20,
|
||||
NIFTY: 25,
|
||||
BANKNIFTY: 15,
|
||||
};
|
||||
|
||||
const EMPTY_GREEKS: PortfolioGreeks = {
|
||||
totalDelta: 0, totalGamma: 0, totalTheta: 0, totalVega: 0, totalRho: 0,
|
||||
perLegGreeks: [], timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const EMPTY_RISK: RiskAssessment = {
|
||||
riskLevel: 'LOW', hasNakedShorts: false, nakedShortLegs: [],
|
||||
maxTheoreticalLoss: 0, netShortCEExposure: 0, netShortPEExposure: 0,
|
||||
concentrationWarning: null, breachedRules: [], timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/** Read SENSEX / NIFTY / BANKNIFTY spot prices from the SQLite market_cache table */
|
||||
function getSpotPrices(): Record<string, number> {
|
||||
try {
|
||||
const rows = db.prepare(`SELECT key, price FROM market_cache`).all() as { key: string; price: number }[];
|
||||
const prices: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
if (r.key === 'SENSEX') prices['SENSEX'] = r.price;
|
||||
if (r.key === 'NIFTY50') prices['NIFTY'] = r.price;
|
||||
if (r.key === 'BANKNIFTY') prices['BANKNIFTY'] = r.price;
|
||||
}
|
||||
return prices;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function mapToAnalysisInput(pos: Position): AnalysisInput | null {
|
||||
// Only analyse F&O option positions with open quantity
|
||||
if (!['OPTIDX', 'OPTSTK'].includes(pos.instrumenttype)) return null;
|
||||
if (pos.netqty === 0) return null;
|
||||
|
||||
const parsed = parseOptionSymbol(pos.tradingsymbol);
|
||||
if (!parsed) return null;
|
||||
|
||||
const daysToExpiry = dteFromExpiry(parsed.expiry);
|
||||
|
||||
return {
|
||||
symbol: pos.tradingsymbol,
|
||||
quantity: pos.netqty,
|
||||
averagePrice: pos.avgPrice,
|
||||
ltp: pos.ltp,
|
||||
unrealizedPnl: pos.unrealisedPnl,
|
||||
realizedPnl: pos.realisedPnl,
|
||||
underlying: parsed.underlying,
|
||||
strikePrice: parsed.strike,
|
||||
optionType: parsed.type,
|
||||
expiry: parsed.expiry,
|
||||
lotSize: LOT_SIZES[parsed.underlying] ?? 25,
|
||||
daysToExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: run the full analysis pipeline on a freshly-polled position list.
|
||||
* Stores the result in analysis_snapshots for the /api/analysis endpoint.
|
||||
*/
|
||||
export function runAnalysis(positions: Position[]): void {
|
||||
try {
|
||||
const spotPrices = getSpotPrices();
|
||||
const inputs: AnalysisInput[] = positions.map(mapToAnalysisInput).filter((p): p is AnalysisInput => p !== null);
|
||||
|
||||
let result: AnalysisResult;
|
||||
if (inputs.length === 0) {
|
||||
result = {
|
||||
classified: [], greeks: EMPTY_GREEKS, risk: EMPTY_RISK,
|
||||
scenarios: [], spotPrices, timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
const classified = classifyPositions(inputs, spotPrices);
|
||||
const greeks = computePortfolioGreeks(classified);
|
||||
const scenarios = runScenarioAnalysis(classified, spotPrices);
|
||||
const risk = assessPortfolioRisk(classified, scenarios, greeks, spotPrices);
|
||||
result = { classified, greeks, risk, scenarios, spotPrices, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
db.prepare(`INSERT INTO analysis_snapshots (data, recorded_at) VALUES (?, datetime('now'))`).run(JSON.stringify(result));
|
||||
console.log(`[analysis] Done — ${inputs.length} option legs, risk=${result.risk.riskLevel}`);
|
||||
} catch (err) {
|
||||
console.error('[analysis] Error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
156
src/ai/classifier.ts
Normal file
156
src/ai/classifier.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { calcGreeks, solveIV } from './greeks.js';
|
||||
import type { AnalysisInput, ClassifiedPosition, PositionCategory } from './types.js';
|
||||
|
||||
const CFG = {
|
||||
FAR_OTM_PCT: 5,
|
||||
DANGER_SHORT_TV_PCT: 15,
|
||||
DANGER_SHORT_MAX_DTE: 5,
|
||||
DEAD_WEIGHT_TV_THRESHOLD: 5,
|
||||
DEAD_WEIGHT_LTP_THRESHOLD: 10,
|
||||
NEAR_EXPIRY_DAYS: 3,
|
||||
UNDER_PRESSURE_LOSS_PCT: 0.5,
|
||||
HYSTERESIS_COUNT: 3,
|
||||
RFR: 0.065,
|
||||
DY: 0.012,
|
||||
};
|
||||
|
||||
// Module-level: persists across poll ticks in the same Node process
|
||||
const dangerCooldown = new Map<string, number>();
|
||||
|
||||
export function classifyPositions(
|
||||
positions: AnalysisInput[],
|
||||
spotPrices: Record<string, number>
|
||||
): ClassifiedPosition[] {
|
||||
return positions.map(pos => classifyOne(pos, spotPrices));
|
||||
}
|
||||
|
||||
function classifyOne(pos: AnalysisInput, spotPrices: Record<string, number>): ClassifiedPosition {
|
||||
const spot = spotPrices[pos.underlying] || 0;
|
||||
const dte = pos.daysToExpiry;
|
||||
const hasSpot = spot > 0 && pos.strikePrice > 0;
|
||||
|
||||
// Moneyness: positive = ITM, negative = OTM
|
||||
let moneyness = 0;
|
||||
if (hasSpot) {
|
||||
moneyness = pos.optionType === 'CE'
|
||||
? ((spot - pos.strikePrice) / spot) * 100
|
||||
: ((pos.strikePrice - spot) / spot) * 100;
|
||||
}
|
||||
|
||||
const intrinsicValue = !hasSpot ? 0 : pos.optionType === 'CE'
|
||||
? Math.max(0, spot - pos.strikePrice)
|
||||
: Math.max(0, pos.strikePrice - spot);
|
||||
|
||||
const timeValue = pos.ltp > 0 ? Math.max(0, pos.ltp - intrinsicValue) : 0;
|
||||
const timeValuePct = pos.ltp > 0 ? (timeValue / pos.ltp) * 100 : 100;
|
||||
|
||||
// Solve IV from market LTP
|
||||
let iv = 0.15;
|
||||
if (hasSpot && dte > 0 && pos.ltp > 0) {
|
||||
const solved = solveIV({ spot, strike: pos.strikePrice, dte, ltp: pos.ltp, type: pos.optionType, rfr: CFG.RFR, dy: CFG.DY });
|
||||
if (isFinite(solved) && solved > 0.01 && solved < 5.0) iv = solved;
|
||||
}
|
||||
|
||||
// Per-unit Greeks
|
||||
const g = (hasSpot && dte > 0)
|
||||
? calcGreeks({ spot, strike: pos.strikePrice, dte, ltp: pos.ltp, type: pos.optionType, rfr: CFG.RFR, dy: CFG.DY })
|
||||
: null;
|
||||
|
||||
const delta = g?.ok ? g.delta : 0;
|
||||
const gamma = g?.ok ? g.gamma : 0;
|
||||
const theta = g?.ok ? g.theta : 0;
|
||||
const vega = g?.ok ? g.vega : 0;
|
||||
const rho = g?.ok ? g.rho : 0;
|
||||
|
||||
const breakeven = pos.optionType === 'CE'
|
||||
? pos.strikePrice + pos.averagePrice
|
||||
: pos.strikePrice - pos.averagePrice;
|
||||
|
||||
// Cost of delay for ITM shorts: how much intrinsic you're carrying per day
|
||||
let costOfDelayPerDay = 0;
|
||||
if (pos.quantity < 0 && intrinsicValue > 0) {
|
||||
const lots = Math.abs(pos.quantity) / pos.lotSize;
|
||||
costOfDelayPerDay = intrinsicValue * pos.lotSize * lots;
|
||||
}
|
||||
|
||||
// === Classification ===
|
||||
const isLong = pos.quantity > 0;
|
||||
const isShort = pos.quantity < 0;
|
||||
const isFarOTM = moneyness < -CFG.FAR_OTM_PCT;
|
||||
const isNearExpiry = dte < CFG.NEAR_EXPIRY_DAYS;
|
||||
const isITM = moneyness > 0 && intrinsicValue > 0;
|
||||
const lossThresh = pos.averagePrice * Math.abs(pos.quantity) * CFG.UNDER_PRESSURE_LOSS_PCT;
|
||||
|
||||
let rawCategory: PositionCategory;
|
||||
if (isShort && isITM && timeValuePct < CFG.DANGER_SHORT_TV_PCT && dte < CFG.DANGER_SHORT_MAX_DTE) {
|
||||
rawCategory = 'DANGER_SHORT';
|
||||
} else if (isShort && moneyness > 3 && pos.unrealizedPnl < 0 && Math.abs(pos.unrealizedPnl) > pos.averagePrice * Math.abs(pos.quantity) * 0.3) {
|
||||
rawCategory = 'DANGER_SHORT';
|
||||
} else if (isLong && isFarOTM && timeValue < CFG.DEAD_WEIGHT_TV_THRESHOLD) {
|
||||
rawCategory = 'DEAD_WEIGHT';
|
||||
} else if (isLong && isNearExpiry && pos.ltp < CFG.DEAD_WEIGHT_LTP_THRESHOLD) {
|
||||
rawCategory = 'DEAD_WEIGHT';
|
||||
} else if (isShort && pos.unrealizedPnl < 0 && Math.abs(pos.unrealizedPnl) > lossThresh) {
|
||||
rawCategory = 'UNDER_PRESSURE';
|
||||
} else if (isLong && pos.unrealizedPnl < 0 && Math.abs(pos.unrealizedPnl) > lossThresh) {
|
||||
rawCategory = 'UNDER_PRESSURE';
|
||||
} else if (isLong && isFarOTM && pos.unrealizedPnl <= 0) {
|
||||
rawCategory = 'HEDGE';
|
||||
} else {
|
||||
rawCategory = 'WORKING';
|
||||
}
|
||||
|
||||
const category = applyHysteresis(pos.symbol, rawCategory);
|
||||
const urgencyScore = computeUrgencyScore(category, timeValuePct, dte, costOfDelayPerDay, pos.unrealizedPnl);
|
||||
|
||||
return {
|
||||
...pos,
|
||||
category,
|
||||
urgencyScore,
|
||||
moneyness: Number(moneyness.toFixed(2)),
|
||||
intrinsicValue: Number(intrinsicValue.toFixed(2)),
|
||||
timeValue: Number(timeValue.toFixed(2)),
|
||||
timeValuePct: Number(timeValuePct.toFixed(1)),
|
||||
breakeven: Number(breakeven.toFixed(1)),
|
||||
impliedVolatility: Number(iv.toFixed(4)),
|
||||
ivPct: Number((iv * 100).toFixed(1)),
|
||||
delta: Number(delta.toFixed(4)),
|
||||
gamma: Number(gamma.toFixed(6)),
|
||||
theta: Number(theta.toFixed(4)),
|
||||
vega: Number(vega.toFixed(4)),
|
||||
rho: Number(rho.toFixed(4)),
|
||||
positionDelta: Number((delta * pos.quantity).toFixed(4)),
|
||||
positionTheta: Number((theta * pos.quantity).toFixed(2)),
|
||||
costOfDelayPerDay: Number(costOfDelayPerDay.toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Prevent DANGER_SHORT from flickering off when spot hovers near strike */
|
||||
function applyHysteresis(symbol: string, raw: PositionCategory): PositionCategory {
|
||||
if (raw === 'DANGER_SHORT') {
|
||||
dangerCooldown.set(symbol, 0);
|
||||
return 'DANGER_SHORT';
|
||||
}
|
||||
const count = dangerCooldown.get(symbol);
|
||||
if (count !== undefined) {
|
||||
const next = count + 1;
|
||||
if (next >= CFG.HYSTERESIS_COUNT) { dangerCooldown.delete(symbol); return raw; }
|
||||
dangerCooldown.set(symbol, next);
|
||||
return 'DANGER_SHORT';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function computeUrgencyScore(
|
||||
category: PositionCategory,
|
||||
tvPct: number, dte: number,
|
||||
costPerDay: number, unrealizedPnl: number
|
||||
): number {
|
||||
if (category !== 'DANGER_SHORT' && category !== 'DEAD_WEIGHT' && category !== 'UNDER_PRESSURE') return 0;
|
||||
let s = 0;
|
||||
if (tvPct < 15) s += Math.min(40, Math.round((15 - tvPct) / 15 * 40));
|
||||
if (dte < 5) s += Math.min(25, Math.round((5 - dte) / 5 * 25));
|
||||
if (costPerDay > 0) s += Math.min(20, Math.round(costPerDay / 5000 * 20));
|
||||
if (unrealizedPnl < 0) s += Math.min(15, Math.round(Math.abs(unrealizedPnl) / 50000 * 15));
|
||||
return Math.min(100, s);
|
||||
}
|
||||
222
src/ai/greeks.ts
Normal file
222
src/ai/greeks.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Black-Scholes IV solver + Greeks calculator for European options.
|
||||
*
|
||||
* Why Black-Scholes for SENSEX weeklies (European, no dividends mid-week)?
|
||||
* - Closed-form, fast (~5ms per position)
|
||||
* - Standard model; bid-ask spreads dominate any model error on weeklies
|
||||
*
|
||||
* Inputs use IST conventions:
|
||||
* - dte: days to expiry (integer or fractional). Expiry assumed at 15:30 IST.
|
||||
* - rfr: 6.5% (RBI repo, default — can be overridden via env)
|
||||
* - dy: 1.2% SENSEX trailing dividend yield (default)
|
||||
*/
|
||||
export interface GreeksInput {
|
||||
spot: number; // Underlying spot price
|
||||
strike: number; // Option strike
|
||||
dte: number; // Days to expiry (fractional ok, e.g. 2.3)
|
||||
ltp: number; // Current option premium
|
||||
type: 'CE' | 'PE';
|
||||
rfr?: number; // Annualised risk-free rate, default 0.065
|
||||
dy?: number; // Annualised dividend yield, default 0.012
|
||||
}
|
||||
|
||||
export interface Greeks {
|
||||
iv: number; // Implied vol, annualised (e.g. 0.16 = 16%)
|
||||
delta: number; // ∂price/∂spot (per 1.0 spot move)
|
||||
gamma: number; // ∂delta/∂spot
|
||||
theta: number; // ∂price/∂t (per day, NOT per year — we divide by 365)
|
||||
vega: number; // ∂price/∂vol (per 1.0 vol point, i.e. 100%)
|
||||
rho: number; // ∂price/∂rfr (per 1.0 rate point)
|
||||
ok: boolean; // false if IV solver failed (deep OTM, illiquid, etc)
|
||||
}
|
||||
|
||||
const SQRT_2PI = Math.sqrt(2 * Math.PI);
|
||||
|
||||
// Standard normal PDF
|
||||
const pdf = (x: number) => Math.exp(-0.5 * x * x) / SQRT_2PI;
|
||||
|
||||
// Standard normal CDF — Abramowitz & Stegun approximation, |err| < 7.5e-8
|
||||
function cdf(x: number): number {
|
||||
const sign = x < 0 ? -1 : 1;
|
||||
x = Math.abs(x) / Math.SQRT2;
|
||||
// A&S formula 7.1.26
|
||||
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
|
||||
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
|
||||
const t = 1.0 / (1.0 + p * x);
|
||||
const y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1) * t * Math.exp(-x * x);
|
||||
return 0.5 * (1.0 + sign * y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Black-Scholes price for European call or put with continuous dividend yield.
|
||||
* Returns price; helper used by IV solver.
|
||||
*/
|
||||
function bsPrice(spot: number, strike: number, t: number, rfr: number, dy: number, vol: number, type: 'CE' | 'PE'): number {
|
||||
if (t <= 0 || vol <= 0) {
|
||||
// At-expiry intrinsic value
|
||||
return type === 'CE' ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0);
|
||||
}
|
||||
const sqrtT = Math.sqrt(t);
|
||||
const d1 = (Math.log(spot / strike) + (rfr - dy + 0.5 * vol * vol) * t) / (vol * sqrtT);
|
||||
const d2 = d1 - vol * sqrtT;
|
||||
const discS = spot * Math.exp(-dy * t);
|
||||
const discK = strike * Math.exp(-rfr * t);
|
||||
if (type === 'CE') return discS * cdf(d1) - discK * cdf(d2);
|
||||
return discK * cdf(-d2) - discS * cdf(-d1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve for implied vol via Newton-Raphson (vega-based), with bisection fallback.
|
||||
*
|
||||
* Returns IV in annualised decimal form (e.g. 0.16 = 16% annualised).
|
||||
* Returns NaN if no convergence — caller should treat as "Greeks unavailable".
|
||||
*/
|
||||
export function solveIV(input: GreeksInput): number {
|
||||
const { spot, strike, dte, ltp, type } = input;
|
||||
const rfr = input.rfr ?? 0.065;
|
||||
const dy = input.dy ?? 0.012;
|
||||
const t = dte / 365;
|
||||
|
||||
if (t <= 0 || ltp <= 0) return NaN;
|
||||
|
||||
// Intrinsic value floor — if premium ≤ intrinsic, IV is effectively 0
|
||||
const intrinsic = type === 'CE' ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0);
|
||||
if (ltp <= intrinsic + 0.01) return 0;
|
||||
|
||||
// Newton-Raphson: start with reasonable IV guess
|
||||
let vol = 0.20; // start 20% annualised
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const price = bsPrice(spot, strike, t, rfr, dy, vol, type);
|
||||
const diff = price - ltp;
|
||||
if (Math.abs(diff) < 0.01) return vol;
|
||||
// Vega in per-1.0-vol terms
|
||||
const sqrtT = Math.sqrt(t);
|
||||
const d1 = (Math.log(spot / strike) + (rfr - dy + 0.5 * vol * vol) * t) / (vol * sqrtT);
|
||||
const vega = spot * Math.exp(-dy * t) * pdf(d1) * sqrtT;
|
||||
if (vega < 1e-6) break; // dead zone — bail to bisection
|
||||
vol = vol - diff / vega;
|
||||
// Sanity guard
|
||||
if (vol < 0.001) vol = 0.001;
|
||||
if (vol > 5) vol = 5;
|
||||
}
|
||||
|
||||
// Bisection fallback (slower but always converges within bracket)
|
||||
let lo = 0.001, hi = 5;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const price = bsPrice(spot, strike, t, rfr, dy, mid, type);
|
||||
if (Math.abs(price - ltp) < 0.01) return mid;
|
||||
if (price < ltp) lo = mid; else hi = mid;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute full Greeks pack for an option position.
|
||||
*
|
||||
* Notes on units:
|
||||
* - delta: per 1 unit move in spot (multiply by qty for position delta)
|
||||
* - gamma: per 1 unit move in spot
|
||||
* - theta: per day (already converted from per-year)
|
||||
* - vega: per 1 vol-point (i.e. price change if IV moves from 0.20 to 0.21 = vega/100)
|
||||
*
|
||||
* Sign convention: position-level (qty applied externally for portfolio aggregation)
|
||||
*/
|
||||
export function calcGreeks(input: GreeksInput): Greeks {
|
||||
const { spot, strike, dte, type } = input;
|
||||
const rfr = input.rfr ?? 0.065;
|
||||
const dy = input.dy ?? 0.012;
|
||||
const t = dte / 365;
|
||||
|
||||
const iv = solveIV(input);
|
||||
|
||||
if (!isFinite(iv) || iv <= 0 || t <= 0) {
|
||||
return { iv: NaN, delta: 0, gamma: 0, theta: 0, vega: 0, rho: 0, ok: false };
|
||||
}
|
||||
|
||||
const sqrtT = Math.sqrt(t);
|
||||
const d1 = (Math.log(spot / strike) + (rfr - dy + 0.5 * iv * iv) * t) / (iv * sqrtT);
|
||||
const d2 = d1 - iv * sqrtT;
|
||||
const discS = spot * Math.exp(-dy * t);
|
||||
const discK = strike * Math.exp(-rfr * t);
|
||||
|
||||
const delta = type === 'CE'
|
||||
? Math.exp(-dy * t) * cdf(d1)
|
||||
: Math.exp(-dy * t) * (cdf(d1) - 1);
|
||||
|
||||
const gamma = (Math.exp(-dy * t) * pdf(d1)) / (spot * iv * sqrtT);
|
||||
|
||||
// Theta per year then convert to per-day
|
||||
const thetaPerYear = type === 'CE'
|
||||
? -(discS * pdf(d1) * iv) / (2 * sqrtT)
|
||||
- rfr * discK * cdf(d2)
|
||||
+ dy * discS * cdf(d1)
|
||||
: -(discS * pdf(d1) * iv) / (2 * sqrtT)
|
||||
+ rfr * discK * cdf(-d2)
|
||||
- dy * discS * cdf(-d1);
|
||||
const theta = thetaPerYear / 365;
|
||||
|
||||
// Vega per 1 vol-point (decimal); divide by 100 if you want per 1% vol
|
||||
const vega = discS * pdf(d1) * sqrtT;
|
||||
|
||||
const rho = type === 'CE'
|
||||
? strike * t * Math.exp(-rfr * t) * cdf(d2) / 100
|
||||
: -strike * t * Math.exp(-rfr * t) * cdf(-d2) / 100;
|
||||
|
||||
return { iv, delta, gamma, theta, vega, rho, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SENSEX option symbol -> strike/type/expiry.
|
||||
* Supports two formats:
|
||||
* - SENSEX2660475500CE -> Jun 04 2026, 75500 CE (YY+M+DD)
|
||||
* - SENSEX26MAY28076100CE -> May 28 2026, 76100 CE (YY+MON+DD)
|
||||
*/
|
||||
export function parseOptionSymbol(sym: string): { underlying: string; strike: number; type: 'CE' | 'PE'; expiry: Date } | null {
|
||||
// Format 1: YY + M(1) + DD
|
||||
let m = sym.match(/^([A-Z]+)(\d{2})(\d{1})(\d{2})(\d+)(CE|PE)$/);
|
||||
if (m) {
|
||||
const yy = m[2], mo = m[3].padStart(2, '0'), dd = m[4];
|
||||
const exp = new Date(`20${yy}-${mo}-${dd}T15:30:00+05:30`);
|
||||
if (!isNaN(exp.getTime())) {
|
||||
return { underlying: m[1], strike: parseInt(m[5]), type: m[6] as 'CE' | 'PE', expiry: exp };
|
||||
}
|
||||
}
|
||||
// Format 2: YY + MON + DD
|
||||
m = sym.match(/^([A-Z]+)(\d{2})(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(\d{2})(\d+)(CE|PE)$/i);
|
||||
if (m) {
|
||||
const months: Record<string, string> = {
|
||||
JAN:'01',FEB:'02',MAR:'03',APR:'04',MAY:'05',JUN:'06',
|
||||
JUL:'07',AUG:'08',SEP:'09',OCT:'10',NOV:'11',DEC:'12',
|
||||
};
|
||||
const yy = m[2], mo = months[m[3].toUpperCase()], dd = m[4];
|
||||
const exp = new Date(`20${yy}-${mo}-${dd}T15:30:00+05:30`);
|
||||
if (!isNaN(exp.getTime())) {
|
||||
return { underlying: m[1], strike: parseInt(m[5]), type: m[6].toUpperCase() as 'CE' | 'PE', expiry: exp };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Days-to-expiry as fractional days (using IST 15:30 closing).
|
||||
* Returns 0 for expired contracts (clamped).
|
||||
*/
|
||||
export function dteFromExpiry(expiry: Date, now: Date = new Date()): number {
|
||||
const ms = expiry.getTime() - now.getTime();
|
||||
return Math.max(0, ms / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Black-Scholes theoretical price at an arbitrary spot using a provided IV.
|
||||
* Used in scenario analysis: compute leg P&L at scenario spots with current market IV.
|
||||
*/
|
||||
export function calcTheoreticalPrice(
|
||||
spot: number, strike: number, dte: number, iv: number,
|
||||
type: 'CE' | 'PE', rfr = 0.065, dy = 0.012
|
||||
): number {
|
||||
if (iv <= 0 || dte <= 0 || spot <= 0) {
|
||||
return type === 'CE' ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0);
|
||||
}
|
||||
return bsPrice(spot, strike, dte / 365, rfr, dy, iv, type);
|
||||
}
|
||||
54
src/ai/portfolio-greeks.ts
Normal file
54
src/ai/portfolio-greeks.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { ClassifiedPosition, LegGreeks, PortfolioGreeks } from './types.js';
|
||||
|
||||
/**
|
||||
* Aggregate per-leg Greeks (already computed by classifier) into portfolio totals.
|
||||
* Per-unit Greeks × quantity = position Greeks.
|
||||
* LTP is used as theoreticalPrice since IV was solved so BS(IV) = LTP.
|
||||
*/
|
||||
export function computePortfolioGreeks(positions: ClassifiedPosition[]): PortfolioGreeks {
|
||||
let totalDelta = 0;
|
||||
let totalGamma = 0;
|
||||
let totalTheta = 0;
|
||||
let totalVega = 0;
|
||||
let totalRho = 0;
|
||||
const perLegGreeks: LegGreeks[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
if (pos.daysToExpiry <= 0) continue;
|
||||
|
||||
const qty = pos.quantity;
|
||||
const pd = pos.delta * qty;
|
||||
const pg = pos.gamma * qty;
|
||||
const pt = pos.theta * qty;
|
||||
const pv = pos.vega * qty;
|
||||
const pr = pos.rho * qty;
|
||||
|
||||
totalDelta += pd;
|
||||
totalGamma += pg;
|
||||
totalTheta += pt;
|
||||
totalVega += pv;
|
||||
totalRho += pr;
|
||||
|
||||
perLegGreeks.push({
|
||||
symbol: pos.symbol,
|
||||
quantity: qty,
|
||||
side: qty > 0 ? 'LONG' : 'SHORT',
|
||||
delta: Number(pd.toFixed(4)),
|
||||
gamma: Number(pg.toFixed(6)),
|
||||
theta: Number(pt.toFixed(2)),
|
||||
vega: Number(pv.toFixed(4)),
|
||||
rho: Number(pr.toFixed(4)),
|
||||
theoreticalPrice: pos.ltp, // BS(IV) = LTP by definition
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalDelta: Number(totalDelta.toFixed(4)),
|
||||
totalGamma: Number(totalGamma.toFixed(6)),
|
||||
totalTheta: Number(totalTheta.toFixed(2)),
|
||||
totalVega: Number(totalVega.toFixed(4)),
|
||||
totalRho: Number(totalRho.toFixed(4)),
|
||||
perLegGreeks,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
144
src/ai/risk-assessor.ts
Normal file
144
src/ai/risk-assessor.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { ClassifiedPosition, PortfolioGreeks, RiskAssessment, ScenarioResult } from './types.js';
|
||||
|
||||
// Hardcoded thresholds (no Prisma DB needed)
|
||||
const T = {
|
||||
maxDelta: 5000,
|
||||
maxGamma: 50,
|
||||
maxVega: 10000,
|
||||
minTheta: -5000,
|
||||
maxAtmConcentration: 4,
|
||||
nearAtmRange: 200,
|
||||
sideImbalanceRatio: 2.0,
|
||||
};
|
||||
|
||||
export function assessPortfolioRisk(
|
||||
positions: ClassifiedPosition[],
|
||||
scenarios: ScenarioResult[],
|
||||
greeks: PortfolioGreeks,
|
||||
spotPrices: Record<string, number>
|
||||
): RiskAssessment {
|
||||
const breachedRules: string[] = [];
|
||||
|
||||
// 1. Naked short detection
|
||||
const { nakedShortLegs, hasNakedShorts } = detectNakedShorts(positions);
|
||||
for (const leg of nakedShortLegs) {
|
||||
breachedRules.push(`Naked short: ${leg.symbol} (qty ${leg.quantity} at ${leg.strikePrice} ${leg.optionType})`);
|
||||
}
|
||||
|
||||
// 2. Near-ATM concentration
|
||||
const concentrationWarning = checkConcentration(positions, spotPrices);
|
||||
if (concentrationWarning) breachedRules.push(concentrationWarning);
|
||||
|
||||
// 3. Side exposure
|
||||
const { netShortCEExposure, netShortPEExposure } = computeShortExposure(positions);
|
||||
if (netShortCEExposure > 0 && netShortPEExposure > 0) {
|
||||
const ratio = Math.max(netShortCEExposure, netShortPEExposure) / Math.min(netShortCEExposure, netShortPEExposure);
|
||||
if (ratio > T.sideImbalanceRatio) {
|
||||
const heavy = netShortCEExposure > netShortPEExposure ? 'CE' : 'PE';
|
||||
breachedRules.push(`Side imbalance: Short ${heavy} exposure is ${ratio.toFixed(1)}x the other side`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Max theoretical loss from pre-computed scenarios
|
||||
let maxTheoreticalLoss = 0;
|
||||
for (const res of scenarios) {
|
||||
for (const pt of res.scenarios) {
|
||||
if (pt.totalPnl < maxTheoreticalLoss) maxTheoreticalLoss = pt.totalPnl;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Greeks breaches
|
||||
const greeksBreaches: NonNullable<RiskAssessment['greeksBreaches']> = {};
|
||||
if (Math.abs(greeks.totalDelta) > T.maxDelta) {
|
||||
greeksBreaches.delta = { current: greeks.totalDelta, limit: T.maxDelta };
|
||||
breachedRules.push(`Delta breach: ${Math.abs(greeks.totalDelta).toFixed(0)} > limit ${T.maxDelta}`);
|
||||
}
|
||||
if (Math.abs(greeks.totalGamma) > T.maxGamma) {
|
||||
greeksBreaches.gamma = { current: greeks.totalGamma, limit: T.maxGamma };
|
||||
breachedRules.push(`Gamma breach: ${Math.abs(greeks.totalGamma).toFixed(2)} > limit ${T.maxGamma}`);
|
||||
}
|
||||
if (Math.abs(greeks.totalVega) > T.maxVega) {
|
||||
greeksBreaches.vega = { current: greeks.totalVega, limit: T.maxVega };
|
||||
breachedRules.push(`Vega breach: ${Math.abs(greeks.totalVega).toFixed(0)} > limit ${T.maxVega}`);
|
||||
}
|
||||
if (greeks.totalTheta < T.minTheta) {
|
||||
greeksBreaches.theta = { current: greeks.totalTheta, limit: T.minTheta };
|
||||
breachedRules.push(`Theta breach: ${greeks.totalTheta.toFixed(0)} exceeds decay limit ${T.minTheta}`);
|
||||
}
|
||||
|
||||
// 6. Risk level
|
||||
let riskLevel: RiskAssessment['riskLevel'] = 'LOW';
|
||||
if (breachedRules.length >= 3 || (hasNakedShorts && Math.abs(maxTheoreticalLoss) > 100000)) {
|
||||
riskLevel = 'CRITICAL';
|
||||
} else if (breachedRules.length >= 2 || hasNakedShorts) {
|
||||
riskLevel = 'HIGH';
|
||||
} else if (breachedRules.length >= 1) {
|
||||
riskLevel = 'MODERATE';
|
||||
}
|
||||
|
||||
return {
|
||||
riskLevel,
|
||||
hasNakedShorts,
|
||||
nakedShortLegs,
|
||||
maxTheoreticalLoss,
|
||||
netShortCEExposure,
|
||||
netShortPEExposure,
|
||||
concentrationWarning,
|
||||
breachedRules,
|
||||
greeksBreaches: Object.keys(greeksBreaches).length > 0 ? greeksBreaches : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function detectNakedShorts(positions: ClassifiedPosition[]) {
|
||||
const shorts = positions.filter(p => p.quantity < 0);
|
||||
const longs = positions.filter(p => p.quantity > 0);
|
||||
const nakedShortLegs: RiskAssessment['nakedShortLegs'] = [];
|
||||
|
||||
for (const short of shorts) {
|
||||
const hasProtection = longs.some(long => {
|
||||
if (long.optionType !== short.optionType) return false;
|
||||
return short.optionType === 'CE'
|
||||
? long.strikePrice > short.strikePrice
|
||||
: long.strikePrice < short.strikePrice;
|
||||
});
|
||||
if (!hasProtection) {
|
||||
nakedShortLegs.push({ symbol: short.symbol, quantity: short.quantity, strikePrice: short.strikePrice, optionType: short.optionType });
|
||||
}
|
||||
}
|
||||
return { nakedShortLegs, hasNakedShorts: nakedShortLegs.length > 0 };
|
||||
}
|
||||
|
||||
function checkConcentration(positions: ClassifiedPosition[], spotPrices: Record<string, number>): string | null {
|
||||
// key = "UNDERLYING:strike" so we can recover both from the map key
|
||||
const strikeMap = new Map<string, { totalLots: number; underlying: string; strike: number }>();
|
||||
|
||||
for (const pos of positions) {
|
||||
if (!pos.strikePrice) continue;
|
||||
const key = `${pos.underlying}:${pos.strikePrice}`;
|
||||
const lots = Math.abs(pos.quantity) / pos.lotSize;
|
||||
const existing = strikeMap.get(key);
|
||||
if (existing) { existing.totalLots += lots; }
|
||||
else { strikeMap.set(key, { totalLots: lots, underlying: pos.underlying, strike: pos.strikePrice }); }
|
||||
}
|
||||
|
||||
for (const [, info] of strikeMap) {
|
||||
const spot = spotPrices[info.underlying] || 0;
|
||||
if (spot > 0 && Math.abs(info.strike - spot) <= T.nearAtmRange && info.totalLots > T.maxAtmConcentration) {
|
||||
return `Concentration: ${info.totalLots.toFixed(0)} lots near-ATM at ${info.strike} (limit: ${T.maxAtmConcentration})`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeShortExposure(positions: ClassifiedPosition[]) {
|
||||
let netShortCEExposure = 0;
|
||||
let netShortPEExposure = 0;
|
||||
for (const pos of positions) {
|
||||
if (pos.quantity >= 0) continue;
|
||||
const notional = Math.abs(pos.quantity) * pos.ltp;
|
||||
if (pos.optionType === 'CE') netShortCEExposure += notional;
|
||||
else if (pos.optionType === 'PE') netShortPEExposure += notional;
|
||||
}
|
||||
return { netShortCEExposure, netShortPEExposure };
|
||||
}
|
||||
64
src/ai/scenario.ts
Normal file
64
src/ai/scenario.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { calcTheoreticalPrice } from './greeks.js';
|
||||
import type { ClassifiedPosition, ScenarioPoint, ScenarioResult } from './types.js';
|
||||
|
||||
const SPOT_MOVES = [-1000, -500, -300, -200, -100, 0, 100, 200, 300, 500, 1000];
|
||||
const RFR = 0.065;
|
||||
const DY = 0.012;
|
||||
|
||||
/**
|
||||
* Compute P&L at various SENSEX spot moves using each leg's implied IV.
|
||||
* IV is taken from the classified position (solved from market LTP) so the
|
||||
* vol surface stays fixed — only the spot changes.
|
||||
*/
|
||||
export function runScenarioAnalysis(
|
||||
positions: ClassifiedPosition[],
|
||||
spotPrices: Record<string, number>
|
||||
): ScenarioResult[] {
|
||||
const grouped = groupByUnderlying(positions);
|
||||
const results: ScenarioResult[] = [];
|
||||
|
||||
for (const [underlying, legs] of Object.entries(grouped)) {
|
||||
let currentSpot = spotPrices[underlying] || 0;
|
||||
if (!currentSpot) {
|
||||
// Estimate from strikes if no market cache yet
|
||||
const strikes = legs.filter(l => l.strikePrice > 0).map(l => l.strikePrice);
|
||||
if (strikes.length) currentSpot = strikes.reduce((a, b) => a + b, 0) / strikes.length;
|
||||
else continue;
|
||||
}
|
||||
|
||||
const scenarios: ScenarioPoint[] = [];
|
||||
for (const move of SPOT_MOVES) {
|
||||
const scenarioSpot = currentSpot + move;
|
||||
if (scenarioSpot <= 0) continue;
|
||||
|
||||
let totalPnl = 0;
|
||||
for (const pos of legs) {
|
||||
if (!pos.strikePrice || pos.daysToExpiry <= 0) continue;
|
||||
const scenarioPrice = calcTheoreticalPrice(
|
||||
scenarioSpot, pos.strikePrice, pos.daysToExpiry,
|
||||
pos.impliedVolatility, pos.optionType, RFR, DY
|
||||
);
|
||||
totalPnl += (scenarioPrice - pos.averagePrice) * pos.quantity;
|
||||
}
|
||||
|
||||
scenarios.push({
|
||||
spotMove: move,
|
||||
spotPrice: Number(scenarioSpot.toFixed(0)),
|
||||
totalPnl: Number(totalPnl.toFixed(0)),
|
||||
});
|
||||
}
|
||||
|
||||
results.push({ underlying, currentSpot, scenarios, timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function groupByUnderlying(positions: ClassifiedPosition[]): Record<string, ClassifiedPosition[]> {
|
||||
const groups: Record<string, ClassifiedPosition[]> = {};
|
||||
for (const pos of positions) {
|
||||
if (!groups[pos.underlying]) groups[pos.underlying] = [];
|
||||
groups[pos.underlying].push(pos);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
106
src/ai/types.ts
Normal file
106
src/ai/types.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Shared types for the position-tracker analysis engine.
|
||||
* No Redis, no Prisma — pure in-process computation fed by polled Angel positions.
|
||||
*/
|
||||
|
||||
export type PositionCategory = 'DEAD_WEIGHT' | 'WORKING' | 'UNDER_PRESSURE' | 'HEDGE' | 'DANGER_SHORT';
|
||||
|
||||
/** Mapped from a live Angel Position into the shape the analysis engine expects */
|
||||
export interface AnalysisInput {
|
||||
symbol: string; // tradingsymbol e.g. SENSEX2660476100CE
|
||||
quantity: number; // netqty (signed: positive=long, negative=short)
|
||||
averagePrice: number; // avgPrice
|
||||
ltp: number;
|
||||
unrealizedPnl: number; // unrealisedPnl
|
||||
realizedPnl: number; // realisedPnl
|
||||
underlying: string; // SENSEX | NIFTY | BANKNIFTY (parsed from symbol)
|
||||
strikePrice: number;
|
||||
optionType: 'CE' | 'PE';
|
||||
expiry: Date;
|
||||
lotSize: number; // 20 for SENSEX, 25 for NIFTY, 15 for BANKNIFTY
|
||||
daysToExpiry: number; // fractional days, clamped to 0
|
||||
}
|
||||
|
||||
export interface ClassifiedPosition extends AnalysisInput {
|
||||
category: PositionCategory;
|
||||
urgencyScore: number; // 0–100
|
||||
moneyness: number; // % from ATM; positive=ITM, negative=OTM
|
||||
intrinsicValue: number;
|
||||
timeValue: number;
|
||||
timeValuePct: number; // timeValue as % of LTP
|
||||
breakeven: number; // strike ± avg_cost
|
||||
impliedVolatility: number; // decimal (e.g. 0.165)
|
||||
ivPct: number; // percentage (e.g. 16.5)
|
||||
// Per-unit Greeks (sign: positive for CE long)
|
||||
delta: number;
|
||||
gamma: number;
|
||||
theta: number; // per day, per unit
|
||||
vega: number;
|
||||
rho: number;
|
||||
// Position-level (scaled by quantity)
|
||||
positionDelta: number;
|
||||
positionTheta: number; // theta per day for the whole position
|
||||
costOfDelayPerDay: number; // intrinsic cost for ITM shorts (₹/day)
|
||||
}
|
||||
|
||||
export interface LegGreeks {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
side: 'LONG' | 'SHORT';
|
||||
delta: number; // position delta
|
||||
gamma: number;
|
||||
theta: number; // per day
|
||||
vega: number;
|
||||
rho: number;
|
||||
theoreticalPrice: number;
|
||||
}
|
||||
|
||||
export interface PortfolioGreeks {
|
||||
totalDelta: number;
|
||||
totalGamma: number;
|
||||
totalTheta: number;
|
||||
totalVega: number;
|
||||
totalRho: number;
|
||||
perLegGreeks: LegGreeks[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ScenarioPoint {
|
||||
spotMove: number;
|
||||
spotPrice: number;
|
||||
totalPnl: number;
|
||||
}
|
||||
|
||||
export interface ScenarioResult {
|
||||
underlying: string;
|
||||
currentSpot: number;
|
||||
scenarios: ScenarioPoint[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface RiskAssessment {
|
||||
riskLevel: 'LOW' | 'MODERATE' | 'HIGH' | 'CRITICAL';
|
||||
hasNakedShorts: boolean;
|
||||
nakedShortLegs: { symbol: string; quantity: number; strikePrice: number; optionType: string }[];
|
||||
maxTheoreticalLoss: number;
|
||||
netShortCEExposure: number;
|
||||
netShortPEExposure: number;
|
||||
concentrationWarning: string | null;
|
||||
breachedRules: string[];
|
||||
greeksBreaches?: {
|
||||
delta?: { current: number; limit: number };
|
||||
gamma?: { current: number; limit: number };
|
||||
vega?: { current: number; limit: number };
|
||||
theta?: { current: number; limit: number };
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
classified: ClassifiedPosition[];
|
||||
greeks: PortfolioGreeks;
|
||||
risk: RiskAssessment;
|
||||
scenarios: ScenarioResult[];
|
||||
spotPrices: Record<string, number>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
|
@ -195,6 +195,21 @@ export function createServer(): express.Application {
|
|||
res.json({ ok: true, data: closed, stalePositions: stale, totalBooked });
|
||||
});
|
||||
|
||||
// ── GET /api/analysis ────────────────────────────────────────────────────
|
||||
// Returns the latest analysis snapshot (classification + Greeks + risk + scenarios)
|
||||
app.get('/api/analysis', (_req, res) => {
|
||||
const row = db.prepare(
|
||||
`SELECT data, recorded_at FROM analysis_snapshots ORDER BY recorded_at DESC LIMIT 1`
|
||||
).get() as { data: string; recorded_at: string } | undefined;
|
||||
|
||||
if (!row) return res.json({ ok: true, data: null, asOf: null });
|
||||
try {
|
||||
res.json({ ok: true, data: JSON.parse(row.data), asOf: row.recorded_at });
|
||||
} catch {
|
||||
res.json({ ok: false, error: 'Corrupt snapshot' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/market ──────────────────────────────────────────────────────
|
||||
app.get('/api/market', async (_req, res) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,14 @@ export function initDb(): void {
|
|||
recorded_at TEXT NOT NULL -- UTC datetime
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_recorded ON pnl_snapshots(recorded_at DESC);
|
||||
|
||||
-- Analysis snapshots: full JSON blob from the analysis engine
|
||||
CREATE TABLE IF NOT EXISTS analysis_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
data TEXT NOT NULL, -- JSON: {classified, greeks, risk, scenarios, spotPrices}
|
||||
recorded_at TEXT NOT NULL -- UTC datetime
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_recorded ON analysis_snapshots(recorded_at DESC);
|
||||
`);
|
||||
|
||||
console.log(`[db] Initialised at ${DB_PATH}`);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { evaluateBand, formatAlertMessage, BandState } from './bands.js';
|
|||
import { db } from '../db/client.js';
|
||||
import { sendTelegram } from '../notify/telegram.js';
|
||||
import { Position } from '../angel/types.js';
|
||||
import { runAnalysis } from '../ai/analyze.js';
|
||||
|
||||
let isPolling = false;
|
||||
|
||||
|
|
@ -76,7 +77,9 @@ export async function pollTick(): Promise<void> {
|
|||
}
|
||||
|
||||
// Evaluate band alerts for each open position
|
||||
await evaluateAlerts(positions, today); recordSnapshot(positions);
|
||||
await evaluateAlerts(positions, today);
|
||||
recordSnapshot(positions);
|
||||
runAnalysis(positions);
|
||||
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -207,7 +210,9 @@ export async function forcePoll(): Promise<void> {
|
|||
if (activeKeys.length > 0) {
|
||||
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);
|
||||
runAnalysis(positions);
|
||||
} catch (err) {
|
||||
console.error(`[poll] Force fetch error: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue