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{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:hover{border-color:var(--coral);color:var(--text)}
|
||||||
.exp-tab.active{background:var(--coral);border-color:var(--coral);color:#fff}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -145,15 +150,20 @@
|
||||||
<div class="nav-l">
|
<div class="nav-l">
|
||||||
<span class="logo">Angel</span>
|
<span class="logo">Angel</span>
|
||||||
<div class="nav-sep"></div>
|
<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>
|
||||||
<div class="nav-r">
|
<div class="nav-r">
|
||||||
<span class="ts" id="ts">—</span>
|
<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>
|
<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" id="theme-btn" onclick="toggleTheme()">☀️</button>
|
||||||
<button class="ibtn" onclick="refresh()">↻</button>
|
<button class="ibtn" onclick="refresh()">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div data-view="dashboard" id="view-dashboard">
|
||||||
<div class="g3">
|
<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 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>
|
<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>
|
||||||
</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>
|
||||||
|
<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>
|
<script>
|
||||||
let theme=localStorage.getItem('theme')||'dark';
|
let theme=localStorage.getItem('theme')||'dark';
|
||||||
document.documentElement.setAttribute('data-theme',theme);
|
document.documentElement.setAttribute('data-theme',theme);
|
||||||
|
|
@ -765,6 +814,128 @@ function setExpiry(btn,exp){
|
||||||
loadPositions();
|
loadPositions();
|
||||||
loadPayoff();
|
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();
|
loadConfig();refresh();
|
||||||
setInterval(refresh,60000);
|
setInterval(refresh,60000);
|
||||||
setInterval(loadMarket,15000);
|
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 });
|
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 ──────────────────────────────────────────────────────
|
// ── GET /api/market ──────────────────────────────────────────────────────
|
||||||
app.get('/api/market', async (_req, res) => {
|
app.get('/api/market', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,14 @@ export function initDb(): void {
|
||||||
recorded_at TEXT NOT NULL -- UTC datetime
|
recorded_at TEXT NOT NULL -- UTC datetime
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshots_recorded ON pnl_snapshots(recorded_at DESC);
|
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}`);
|
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 { db } from '../db/client.js';
|
||||||
import { sendTelegram } from '../notify/telegram.js';
|
import { sendTelegram } from '../notify/telegram.js';
|
||||||
import { Position } from '../angel/types.js';
|
import { Position } from '../angel/types.js';
|
||||||
|
import { runAnalysis } from '../ai/analyze.js';
|
||||||
|
|
||||||
let isPolling = false;
|
let isPolling = false;
|
||||||
|
|
||||||
|
|
@ -76,7 +77,9 @@ export async function pollTick(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate band alerts for each open position
|
// Evaluate band alerts for each open position
|
||||||
await evaluateAlerts(positions, today); recordSnapshot(positions);
|
await evaluateAlerts(positions, today);
|
||||||
|
recordSnapshot(positions);
|
||||||
|
runAnalysis(positions);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|
@ -207,7 +210,9 @@ export async function forcePoll(): Promise<void> {
|
||||||
if (activeKeys.length > 0) {
|
if (activeKeys.length > 0) {
|
||||||
db.prepare(`UPDATE positions SET is_closed = 1 WHERE key NOT IN (${activeKeys.map(() => "?").join(",")}) AND netqty = 0`).run(...activeKeys);
|
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) {
|
} catch (err) {
|
||||||
console.error(`[poll] Force fetch error: ${err instanceof Error ? err.message : err}`);
|
console.error(`[poll] Force fetch error: ${err instanceof Error ? err.message : err}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue