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:
Manohar 2026-06-19 14:07:24 +05:30
parent bb8a00c196
commit dca5fea679
11 changed files with 1045 additions and 3 deletions

View file

@ -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">&#8212;</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()">&#9728;&#65039;</button>
<button class="ibtn" onclick="refresh()">&#8635;</button>
</div>
</nav>
<div data-view="dashboard" id="view-dashboard">
<div class="g3">
<div class="card pcard pu"><div class="plbl">Unrealised P&amp;L</div><div class="pval fl" id="v-u">&#8212;</div><div class="psub">Open positions MTM</div></div>
<div class="card pcard pr"><div class="plbl">Realised P&amp;L</div><div class="pval fl" id="v-r">&#8212;</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">&#8212;</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">&#8212;</div><div class="psub">Daily time decay (&#8377;)</div></div>
<div class="card pcard pr"><div class="plbl">Net Vega</div><div class="pval fl" id="a-vega">&#8212;</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">&#8212;</div><div class="psub" id="a-risk-sub">&#8212;</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">&#9660;</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&#x394;</th><th>&#x398;/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&#8230;</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">&#9660;</span></div>
<div class="collapse-body"><div id="a-risk-body" style="padding:14px 20px;color:var(--text3);font-size:.8rem">Loading&#8230;</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&amp;L at SENSEX spot moves (fixed vol)</span>
<span class="collapse-arrow">&#9660;</span>
</div>
<div class="collapse-body"><div id="a-scen-body" style="padding:14px 20px;color:var(--text3);font-size:.8rem">Loading&#8230;</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+' &mdash; 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
View 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
View 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
View 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);
}

View 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
View 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
View 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
View 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; // 0100
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;
}

View file

@ -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 {

View file

@ -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}`);

View file

@ -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}`);
}