feat: initial position tracker scaffold

This commit is contained in:
Manohar 2026-05-08 11:22:05 +00:00
commit 971c59df11
16 changed files with 1343 additions and 0 deletions

18
.env.example Normal file
View file

@ -0,0 +1,18 @@
# Angel Broking SmartAPI credentials
ANGEL_CLIENT_ID=your_client_id
ANGEL_MPIN=your_4_digit_mpin
ANGEL_API_KEY=your_api_key_from_smartapi_portal
ANGEL_TOTP_SEED=BASE32_SEED_FROM_QR_CODE # e.g. JBSWY3DPEHPK3PXP
# Telegram
TELEGRAM_BOT_TOKEN=your_bot_token
TELEGRAM_CHAT_ID=your_chat_id
# Service config
PORT=3457
POLL_INTERVAL_SECONDS=60 # how often to poll Angel API during market hours
ALERT_THRESHOLD_PCT=5 # trigger alert when P&L moves this % from anchor
ALERT_MIN_ABS_INR=100 # minimum absolute ₹ move to fire (guards near-zero P&L)
# SQLite path (inside container, bind-mounted to ./data on host)
DB_PATH=/app/data/tracker.db

42
Dockerfile Normal file
View file

@ -0,0 +1,42 @@
# ── Build stage ──────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies (including dev deps for tsc)
COPY package*.json ./
RUN npm ci
# Copy source and compile TypeScript
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
# Production deps only — also rebuild better-sqlite3 for Alpine (musl libc)
COPY package*.json ./
RUN npm ci --omit=dev && \
npm rebuild better-sqlite3 && \
npm cache clean --force
# Copy compiled JS + static UI
COPY --from=builder /app/dist ./dist
COPY public ./public
# Data directory will be bind-mounted; create it so it exists if not mounted
RUN mkdir -p /app/data
# Non-root user for security
RUN addgroup -S tracker && adduser -S tracker -G tracker && \
chown -R tracker:tracker /app
USER tracker
EXPOSE 3457
# dotenv/config is imported in index.ts — reads .env if present.
# In prod, pass env vars via Dokploy / docker-compose env_file instead.
CMD ["node", "dist/index.js"]

26
docker-compose.yml Normal file
View file

@ -0,0 +1,26 @@
services:
position-tracker:
build: .
container_name: position-tracker
restart: unless-stopped
env_file: .env
ports:
- "3457:3457" # internal only; Traefik handles external
volumes:
- ./data:/app/data # SQLite persistence
networks:
- dokploy-network
labels:
# Traefik routing
- "traefik.enable=true"
- "traefik.http.routers.position-tracker.rule=Host(`angel.manohargupta.com`)"
- "traefik.http.routers.position-tracker.entrypoints=websecure"
- "traefik.http.routers.position-tracker.tls=true"
- "traefik.http.routers.position-tracker.tls.certresolver=letsencrypt"
- "traefik.http.services.position-tracker.loadbalancer.server.port=3457"
# Security headers middleware (reuse existing if defined, or inline)
- "traefik.http.routers.position-tracker.middlewares=secHeaders@file"
networks:
dokploy-network:
external: true

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "position-tracker",
"version": "1.0.0",
"description": "Angel Broking position tracker with P&L band alerts",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn src/index.ts"
},
"dependencies": {
"better-sqlite3": "^9.4.3",
"express": "^4.18.3",
"node-cron": "^3.0.3",
"otpauth": "^9.3.4",
"axios": "^1.6.7",
"date-fns": "^3.3.1",
"date-fns-tz": "^3.1.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.21",
"@types/node": "^20.11.17",
"@types/node-cron": "^3.0.11",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
}
}

200
public/index.html Normal file
View file

@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Position Tracker</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #0f172a; color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; }
.pnl-up { color: #4ade80; }
.pnl-down { color: #f87171; }
.pnl-flat { color: #94a3b8; }
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
.badge-eq { background: #1e3a5f; color: #93c5fd; }
.badge-opt { background: #3b1f5f; color: #d8b4fe; }
.badge-fut { background: #1f3b2a; color: #86efac; }
.badge-closed { background: #374151; color: #9ca3af; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
.live-dot { animation: pulse 2s infinite; }
</style>
</head>
<body class="min-h-screen p-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">📊 Position Tracker</h1>
<p class="text-slate-400 text-sm" id="last-updated">Loading...</p>
</div>
<div class="flex items-center gap-3">
<span id="market-status" class="badge"></span>
<button onclick="refresh()" class="bg-slate-700 hover:bg-slate-600 text-white text-sm px-3 py-1.5 rounded-lg">↺ Refresh</button>
</div>
</div>
<!-- Summary cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6" id="summary-cards">
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Total P&L</p><p class="text-2xl font-bold" id="total-pnl"></p></div>
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Open Positions</p><p class="text-2xl font-bold text-white" id="open-count"></p></div>
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Alerts Today</p><p class="text-2xl font-bold text-amber-400" id="alert-count"></p></div>
<div class="card p-4"><p class="text-slate-400 text-xs mb-1">Last Error</p><p class="text-sm text-red-400 truncate" id="last-error">None</p></div>
</div>
<!-- Positions table -->
<div class="card mb-6 overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<h2 class="font-semibold text-white">Open Positions</h2>
<div id="live-indicator" class="flex items-center gap-2 text-xs text-green-400">
<span class="live-dot w-2 h-2 rounded-full bg-green-400 inline-block"></span> Live
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-slate-400 border-b border-slate-700">
<th class="text-left px-4 py-2">Symbol</th>
<th class="text-right px-4 py-2">Qty</th>
<th class="text-right px-4 py-2">LTP</th>
<th class="text-right px-4 py-2">Avg</th>
<th class="text-right px-4 py-2">Unrealised P&L</th>
<th class="text-right px-4 py-2">Realised</th>
<th class="text-right px-4 py-2">Total P&L</th>
<th class="text-center px-4 py-2">Type</th>
</tr>
</thead>
<tbody id="positions-tbody">
<tr><td colspan="8" class="text-center py-8 text-slate-500">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Recent alerts -->
<div class="card overflow-hidden">
<div class="px-4 py-3 border-b border-slate-700">
<h2 class="font-semibold text-white">Recent Alerts</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-slate-400 border-b border-slate-700">
<th class="text-left px-4 py-2">Symbol</th>
<th class="text-right px-4 py-2">P&L at Alert</th>
<th class="text-right px-4 py-2">From Anchor</th>
<th class="text-right px-4 py-2">Move</th>
<th class="text-right px-4 py-2">LTP</th>
<th class="text-right px-4 py-2">Time (IST)</th>
</tr>
</thead>
<tbody id="alerts-tbody">
<tr><td colspan="6" class="text-center py-8 text-slate-500">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
// ── helpers ──────────────────────────────────────────────────────────────
const fmt = (n, decimals=0) => {
if (n === null || n === undefined) return '—';
const abs = Math.abs(n);
const s = abs.toLocaleString('en-IN', {maximumFractionDigits: decimals});
return (n >= 0 ? '+' : '-') + '₹' + s;
};
const pnlClass = n => n > 0 ? 'pnl-up' : n < 0 ? 'pnl-down' : 'pnl-flat';
const toIST = utcStr => {
const d = new Date(utcStr + (utcStr.includes('Z') ? '' : 'Z'));
return d.toLocaleString('en-IN', {timeZone:'Asia/Kolkata', hour12:false,
month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
};
const instrumentBadge = t => {
if (!t) return '';
if (t.includes('OPT')) return `<span class="badge badge-opt">${t}</span>`;
if (t.includes('FUT')) return `<span class="badge badge-fut">${t}</span>`;
return `<span class="badge badge-eq">${t}</span>`;
};
// ── data fetchers ─────────────────────────────────────────────────────────
async function loadPositions() {
const r = await fetch('/api/positions');
const {data} = await r.json();
const open = data.filter(p => p.is_closed === 0);
const tbody = document.getElementById('positions-tbody');
document.getElementById('open-count').textContent = open.length;
const totalPnl = open.reduce((s, p) => s + p.total_pnl, 0);
const el = document.getElementById('total-pnl');
el.textContent = fmt(totalPnl);
el.className = 'text-2xl font-bold ' + pnlClass(totalPnl);
if (open.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-8 text-slate-500">No open positions</td></tr>`;
return;
}
tbody.innerHTML = open.map(p => `
<tr class="border-b border-slate-800 hover:bg-slate-800/50">
<td class="px-4 py-2.5 font-medium text-white">${p.tradingsymbol}<br>
<span class="text-slate-500 text-xs">${p.exchange} · ${p.producttype}</span></td>
<td class="px-4 py-2.5 text-right text-white">${p.netqty}</td>
<td class="px-4 py-2.5 text-right text-white">₹${(+p.ltp).toFixed(2)}</td>
<td class="px-4 py-2.5 text-right text-slate-300">₹${(+p.avg_price).toFixed(2)}</td>
<td class="px-4 py-2.5 text-right font-medium ${pnlClass(p.unrealised_pnl)}">${fmt(p.unrealised_pnl)}</td>
<td class="px-4 py-2.5 text-right ${pnlClass(p.realised_pnl)}">${fmt(p.realised_pnl)}</td>
<td class="px-4 py-2.5 text-right font-bold ${pnlClass(p.total_pnl)}">${fmt(p.total_pnl)}</td>
<td class="px-4 py-2.5 text-center">${instrumentBadge(p.instrumenttype)}</td>
</tr>
`).join('');
}
async function loadAlerts() {
const r = await fetch('/api/alerts?limit=20');
const {data} = await r.json();
const tbody = document.getElementById('alerts-tbody');
const today = new Date().toISOString().slice(0,10);
const todayCount = data.filter(a => a.alerted_at.startsWith(today)).length;
document.getElementById('alert-count').textContent = todayCount;
if (data.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="text-center py-8 text-slate-500">No alerts yet</td></tr>`;
return;
}
tbody.innerHTML = data.map(a => {
const dir = a.direction === 'up' ? '🟢' : '🔴';
return `
<tr class="border-b border-slate-800 hover:bg-slate-800/50">
<td class="px-4 py-2.5 font-medium text-white">${dir} ${a.tradingsymbol}</td>
<td class="px-4 py-2.5 text-right font-medium ${pnlClass(a.current_pnl)}">${fmt(a.current_pnl)}</td>
<td class="px-4 py-2.5 text-right text-slate-300">${fmt(a.anchor_pnl)}</td>
<td class="px-4 py-2.5 text-right ${pnlClass(a.delta_abs)}">
${fmt(a.delta_abs)} (${a.delta_pct > 0 ? '+' : ''}${(+a.delta_pct).toFixed(1)}%)
</td>
<td class="px-4 py-2.5 text-right text-slate-300">₹${(+a.ltp).toFixed(2)}</td>
<td class="px-4 py-2.5 text-right text-slate-400">${toIST(a.alerted_at)}</td>
</tr>`;
}).join('');
}
async function loadHealth() {
const r = await fetch('/api/health');
const d = await r.json();
const ms = document.getElementById('market-status');
ms.textContent = d.marketOpen ? '🟢 Market Open' : '🔴 Market Closed';
ms.className = 'badge ' + (d.marketOpen ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400');
document.getElementById('last-error').textContent = d.lastError ? d.lastError.error.slice(0,40) : 'None';
document.getElementById('last-updated').textContent = 'Updated: ' + new Date().toLocaleTimeString('en-IN', {timeZone:'Asia/Kolkata'}) + ' IST';
}
async function refresh() {
await Promise.all([loadPositions(), loadAlerts(), loadHealth()]);
}
// Initial load + auto-refresh every 60s
refresh();
setInterval(refresh, 60_000);
</script>
</body>
</html>

123
src/angel/auth.ts Normal file
View file

@ -0,0 +1,123 @@
import axios from 'axios';
import { TOTP } from 'otpauth';
import { AngelAuthResponse } from './types.js';
// Token state — module-level singleton (one auth context per process)
let jwtToken: string | null = null;
let refreshToken: string | null = null;
let tokenExpiry: Date | null = null;
const BASE_URL = 'https://apiconnect.angelbroking.com';
/**
* Generate current TOTP from the base32 seed stored in .env
* SmartAPI expects standard RFC 6238 TOTP (30s window, 6 digits, SHA1)
*/
function generateTOTP(): string {
const seed = process.env.ANGEL_TOTP_SEED;
if (!seed) throw new Error('ANGEL_TOTP_SEED not set in environment');
const totp = new TOTP({
issuer: 'AngelBroking',
label: process.env.ANGEL_CLIENT_ID || 'angel',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: seed, // otpauth accepts base32 string directly
});
return totp.generate();
}
/**
* Full login called at startup and when token expires.
* SmartAPI tokens are valid for 24h but we refresh proactively at 23h.
*/
export async function login(): Promise<void> {
const clientCode = process.env.ANGEL_CLIENT_ID;
const mpin = process.env.ANGEL_MPIN;
const apiKey = process.env.ANGEL_API_KEY;
if (!clientCode || !mpin || !apiKey) {
throw new Error('Missing ANGEL_CLIENT_ID / ANGEL_MPIN / ANGEL_API_KEY in env');
}
const totp = generateTOTP();
console.log(`[auth] Logging in as ${clientCode}, TOTP=${totp}`);
const response = await axios.post<AngelAuthResponse>(
`${BASE_URL}/rest/auth/angelbroking/user/v1/loginByPassword`,
{
clientcode: clientCode,
password: mpin,
totp,
},
{
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-UserType': 'USER',
'X-SourceID': 'WEB',
'X-ClientLocalIP': '127.0.0.1',
'X-ClientPublicIP': '127.0.0.1',
'X-MACAddress': '00:00:00:00:00:00',
'X-PrivateKey': apiKey,
},
}
);
const { status, message, data } = response.data;
if (!status || !data) {
throw new Error(`Angel login failed: ${message}`);
}
jwtToken = data.jwtToken;
refreshToken = data.refreshToken;
// Set expiry to 23 hours from now (token is valid 24h, we refresh 1h early)
tokenExpiry = new Date(Date.now() + 23 * 60 * 60 * 1000);
console.log(`[auth] Login success. Token valid until ${tokenExpiry.toISOString()}`);
}
/**
* Get a valid JWT refreshes automatically if within 1h of expiry.
* Call this before every API request.
*/
export async function getToken(): Promise<string> {
const now = new Date();
// No token yet, or past expiry
if (!jwtToken || !tokenExpiry || now >= tokenExpiry) {
await login();
}
return jwtToken!;
}
/**
* Get the API key header value needed on every request
*/
export function getApiKey(): string {
const key = process.env.ANGEL_API_KEY;
if (!key) throw new Error('ANGEL_API_KEY not set');
return key;
}
/**
* Build standard headers for authenticated API calls
*/
export async function getAuthHeaders(): Promise<Record<string, string>> {
const token = await getToken();
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-UserType': 'USER',
'X-SourceID': 'WEB',
'X-ClientLocalIP': '127.0.0.1',
'X-ClientPublicIP': '127.0.0.1',
'X-MACAddress': '00:00:00:00:00:00',
'X-PrivateKey': getApiKey(),
};
}

133
src/angel/client.ts Normal file
View file

@ -0,0 +1,133 @@
import axios from 'axios';
import { getAuthHeaders } from './auth.js';
import {
AngelPositionsResponse,
AngelHoldingsResponse,
AngelPosition,
AngelHolding,
Position,
} from './types.js';
const BASE_URL = 'https://apiconnect.angelbroking.com';
/**
* Fetch all open positions (F&O + intraday equity)
* Returns empty array if no open positions (not an error)
*/
export async function fetchPositions(): Promise<AngelPosition[]> {
const headers = await getAuthHeaders();
const response = await axios.get<AngelPositionsResponse>(
`${BASE_URL}/rest/secure/angelbroking/order/v1/getPosition`,
{ headers }
);
if (!response.data.status) {
// errorcode AB8050 = "No Data" (no open positions) — treat as empty, not error
if (response.data.errorcode === 'AB8050') return [];
throw new Error(`Positions fetch failed: ${response.data.message}`);
}
return response.data.data ?? [];
}
/**
* Fetch equity holdings (delivery / long-term)
*/
export async function fetchHoldings(): Promise<AngelHolding[]> {
const headers = await getAuthHeaders();
const response = await axios.get<AngelHoldingsResponse>(
`${BASE_URL}/rest/secure/angelbroking/portfolio/v1/getHolding`,
{ headers }
);
if (!response.data.status) {
if (response.data.errorcode === 'AB8050') return [];
throw new Error(`Holdings fetch failed: ${response.data.message}`);
}
return response.data.data ?? [];
}
/**
* Normalise raw AngelPosition Position
* Angel API returns everything as strings; we parse here once.
*/
function normalisePosition(p: AngelPosition): Position | null {
const netqty = parseFloat(p.netqty);
// Skip positions with zero net qty (fully closed intraday)
if (netqty === 0) return null;
const ltp = parseFloat(p.ltp) || 0;
const unrealised = parseFloat(p.unrealised) || 0;
const realised = parseFloat(p.realised) || 0;
const netAmount = parseFloat(p.netamount) || 0;
// avgPrice = total cost / qty (approximate, fine for display)
const avgPrice = netqty !== 0 ? Math.abs(netAmount / netqty) : 0;
return {
key: `${p.exchange}:${p.tradingsymbol}`,
exchange: p.exchange,
tradingsymbol: p.tradingsymbol,
instrumenttype: p.instrumenttype,
producttype: p.producttype,
netqty,
ltp,
avgPrice,
unrealisedPnl: unrealised,
realisedPnl: realised,
totalPnl: unrealised + realised,
netAmount,
source: 'position',
};
}
/**
* Normalise AngelHolding Position
*/
function normaliseHolding(h: AngelHolding): Position | null {
if (h.quantity === 0) return null;
const cost = h.quantity * h.close; // approximate cost basis using prev close
return {
key: `${h.exchange}:${h.tradingsymbol}`,
exchange: h.exchange,
tradingsymbol: h.tradingsymbol,
instrumenttype: 'EQ',
producttype: 'DELIVERY',
netqty: h.quantity,
ltp: h.ltp,
avgPrice: cost / h.quantity,
unrealisedPnl: h.profitandloss,
realisedPnl: 0,
totalPnl: h.profitandloss,
netAmount: cost,
source: 'holding',
};
}
/**
* Fetch and normalise ALL open positions across positions + holdings.
* Deduplicates by key (position wins over holding for same symbol).
*/
export async function fetchAllPositions(): Promise<Position[]> {
const [rawPositions, rawHoldings] = await Promise.all([
fetchPositions(),
fetchHoldings(),
]);
const map = new Map<string, Position>();
// Holdings first (lower priority)
for (const h of rawHoldings) {
const p = normaliseHolding(h);
if (p) map.set(p.key, p);
}
// Positions override (intraday / F&O takes precedence)
for (const raw of rawPositions) {
const p = normalisePosition(raw);
if (p) map.set(p.key, p);
}
return Array.from(map.values());
}

119
src/angel/types.ts Normal file
View file

@ -0,0 +1,119 @@
// Angel SmartAPI response types
// Reference: https://smartapi.angelbroking.com/docs
export interface AngelAuthResponse {
status: boolean;
message: string;
errorcode: string;
data: {
jwtToken: string;
refreshToken: string;
feedToken: string;
clientcode: string;
name: string;
} | null;
}
export interface AngelPosition {
exchange: string; // NSE, BSE, NFO, MCX
symboltoken: string;
tradingsymbol: string;
producttype: string; // CARRYFORWARD, INTRADAY, DELIVERY
symbolname: string;
instrumenttype: string; // OPTIDX, OPTSTK, FUTSTK, EQ, etc.
priceden: string;
pricenum: string;
genprice: string;
precision: string;
multiplier: string;
boardlotsize: string;
buyprice: string;
sellprice: string;
buyqty: string;
sellqty: string;
buyamount: string;
sellamount: string;
netqty: string; // positive = long, negative = short
netprice: string;
netamount: string; // total cost
cfbuyqty: string; // carry-forward buy qty
cfsellqty: string;
cfbuyamount: string;
cfsellamount: string;
cfbuyavgprice: string;
cfsellavgprice: string;
totalbuyvalue: string;
totalsellvalue: string;
cfnetqty: string;
cfnetamount: string;
totalbuyavgprice: string;
totalsellavgprice: string;
close: string; // previous close
ltp: string; // last traded price
realised: string; // realised P&L (closed portion)
unrealised: string; // unrealised P&L (open portion)
day_buy_qty: string;
day_sell_qty: string;
day_buy_price: string;
day_sell_price: string;
day_buy_value: string;
day_sell_value: string;
optiongreeks?: {
delta?: string;
gamma?: string;
theta?: string;
vega?: string;
rho?: string;
};
}
export interface AngelPositionsResponse {
status: boolean;
message: string;
errorcode: string;
data: AngelPosition[] | null;
}
export interface AngelHolding {
tradingsymbol: string;
exchange: string;
isin: string;
t1quantity: number;
realisedquantity: number;
quantity: number;
authorisedquantity: number;
profitandloss: number;
pnlpercentage: number;
close: number;
ltp: number;
symboltoken: string;
collateralquantity: number | null;
collateraltype: string | null;
haircut: number;
product: string;
holdingsvalue: number;
}
export interface AngelHoldingsResponse {
status: boolean;
message: string;
errorcode: string;
data: AngelHolding[] | null;
}
// Normalised internal position type (parsed from strings)
export interface Position {
key: string; // unique: exchange:tradingsymbol
exchange: string;
tradingsymbol: string;
instrumenttype: string;
producttype: string;
netqty: number;
ltp: number;
avgPrice: number;
unrealisedPnl: number;
realisedPnl: number;
totalPnl: number; // unrealised + realised
netAmount: number;
source: 'position' | 'holding';
}

105
src/api/server.ts Normal file
View file

@ -0,0 +1,105 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { db } from '../db/client.js';
import { isMarketOpen } from '../tracker/market-hours.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export function createServer(): express.Application {
const app = express();
app.use(express.json());
// Serve the static UI from /public
const publicDir = path.resolve(__dirname, '../../public');
app.use(express.static(publicDir));
// ── GET /api/positions ────────────────────────────────────────────────────
// Returns all currently tracked positions (open + recently closed)
app.get('/api/positions', (_req, res) => {
const rows = db.prepare(`
SELECT key, exchange, tradingsymbol, instrumenttype, producttype,
netqty, ltp, avg_price, unrealised_pnl, realised_pnl, total_pnl,
source, is_closed, updated_at
FROM positions
ORDER BY ABS(total_pnl) DESC
`).all();
res.json({ ok: true, data: rows });
});
// ── GET /api/alerts ───────────────────────────────────────────────────────
// Alert history, newest first, optional ?limit=N&symbol=X
app.get('/api/alerts', (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const symbol = req.query.symbol as string | undefined;
let query = `SELECT * FROM alerts`;
const params: (string | number)[] = [];
if (symbol) {
query += ` WHERE tradingsymbol = ?`;
params.push(symbol);
}
query += ` ORDER BY alerted_at DESC LIMIT ?`;
params.push(limit);
const rows = db.prepare(query).all(...params);
res.json({ ok: true, data: rows });
});
// ── GET /api/config ───────────────────────────────────────────────────────
app.get('/api/config', (_req, res) => {
const rows = db.prepare(`SELECT * FROM position_config`).all();
res.json({
ok: true,
global: {
alertThresholdPct: parseFloat(process.env.ALERT_THRESHOLD_PCT || '5'),
alertMinAbsInr: parseFloat(process.env.ALERT_MIN_ABS_INR || '100'),
pollIntervalSeconds: parseInt(process.env.POLL_INTERVAL_SECONDS || '60'),
},
overrides: rows,
});
});
// ── PUT /api/config/:key ──────────────────────────────────────────────────
// Set per-position threshold or mute
app.put('/api/config/:key', (req, res) => {
const { key } = req.params;
const { alert_threshold_pct, muted_until, notes } = req.body;
db.prepare(`
INSERT INTO position_config (position_key, alert_threshold_pct, muted_until, notes)
VALUES (?, ?, ?, ?)
ON CONFLICT(position_key) DO UPDATE SET
alert_threshold_pct = excluded.alert_threshold_pct,
muted_until = excluded.muted_until,
notes = excluded.notes
`).run(key, alert_threshold_pct ?? null, muted_until ?? null, notes ?? null);
res.json({ ok: true });
});
// ── GET /api/health ───────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
const lastError = db.prepare(
`SELECT error, occurred_at FROM poll_errors ORDER BY occurred_at DESC LIMIT 1`
).get() as { error: string; occurred_at: string } | undefined;
const posCount = (db.prepare(`SELECT COUNT(*) as n FROM positions WHERE is_closed = 0`).get() as { n: number }).n;
res.json({
ok: true,
marketOpen: isMarketOpen(),
openPositions: posCount,
lastError: lastError ?? null,
uptime: Math.floor(process.uptime()),
});
});
// Catch-all: serve index.html for SPA routing
app.get('*', (_req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
return app;
}

85
src/db/client.ts Normal file
View file

@ -0,0 +1,85 @@
import Database from 'better-sqlite3';
import path from 'path';
const DB_PATH = process.env.DB_PATH || '/app/data/tracker.db';
// Ensure data directory exists (for local dev where /app/data may not exist)
import { mkdirSync } from 'fs';
mkdirSync(path.dirname(DB_PATH), { recursive: true });
export const db = new Database(DB_PATH);
// WAL mode for better concurrent read performance (API reads while cron writes)
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
/**
* Run migrations idempotent, safe to call on every startup
*/
export function initDb(): void {
db.exec(`
-- Current snapshot of open positions
CREATE TABLE IF NOT EXISTS positions (
key TEXT PRIMARY KEY, -- 'NSE:RELIANCE' or 'NFO:NIFTY24DECPE'
exchange TEXT NOT NULL,
tradingsymbol TEXT NOT NULL,
instrumenttype TEXT NOT NULL,
producttype TEXT NOT NULL,
netqty REAL NOT NULL,
ltp REAL NOT NULL,
avg_price REAL NOT NULL,
unrealised_pnl REAL NOT NULL,
realised_pnl REAL NOT NULL,
total_pnl REAL NOT NULL,
source TEXT NOT NULL, -- 'position' | 'holding'
is_closed INTEGER DEFAULT 0,
updated_at TEXT NOT NULL
);
-- Per-position band state (anchor for alert logic)
CREATE TABLE IF NOT EXISTS band_state (
position_key TEXT PRIMARY KEY,
anchor_pnl REAL NOT NULL,
last_alert_pnl REAL NOT NULL,
last_alert_time TEXT, -- ISO8601 UTC or null
trading_date TEXT NOT NULL -- 'YYYY-MM-DD' IST for daily reset
);
-- Alert history
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
position_key TEXT NOT NULL,
tradingsymbol TEXT NOT NULL,
current_pnl REAL NOT NULL,
anchor_pnl REAL NOT NULL,
delta_abs REAL NOT NULL,
delta_pct REAL NOT NULL,
direction TEXT NOT NULL, -- 'up' | 'down'
ltp REAL NOT NULL,
netqty REAL NOT NULL,
alerted_at TEXT NOT NULL -- datetime('now') = UTC
);
-- Per-position threshold config (override global 5%)
CREATE TABLE IF NOT EXISTS position_config (
position_key TEXT PRIMARY KEY,
alert_threshold_pct REAL, -- NULL = use global default
muted_until TEXT, -- ISO8601 UTC, NULL = not muted
notes TEXT
);
-- Error log for health endpoint
CREATE TABLE IF NOT EXISTS poll_errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
error TEXT NOT NULL,
occurred_at TEXT NOT NULL
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_alerts_position ON alerts(position_key);
CREATE INDEX IF NOT EXISTS idx_alerts_alerted_at ON alerts(alerted_at DESC);
CREATE INDEX IF NOT EXISTS idx_positions_closed ON positions(is_closed);
`);
console.log(`[db] Initialised at ${DB_PATH}`);
}

56
src/index.ts Normal file
View file

@ -0,0 +1,56 @@
import 'dotenv/config';
import cron from 'node-cron';
import { initDb } from './db/client.js';
import { login } from './angel/auth.js';
import { pollTick } from './tracker/poll.js';
import { createServer } from './api/server.js';
import { sendServiceNotification } from './notify/telegram.js';
const PORT = parseInt(process.env.PORT || '3457');
const POLL_SECONDS = parseInt(process.env.POLL_INTERVAL_SECONDS || '60');
async function main() {
console.log('[main] Position Tracker starting...');
// 1. Init SQLite (creates tables if first run)
initDb();
// 2. Initial Angel login (will throw if creds wrong — fail fast at startup)
await login();
// 3. Start HTTP API server
const app = createServer();
app.listen(PORT, '0.0.0.0', () => {
console.log(`[main] API server listening on port ${PORT}`);
});
// 4. Run first poll immediately so dashboard shows data on startup
await pollTick();
// 5. Schedule recurring poll
// node-cron doesn't support sub-minute; for 60s we use a cron expression.
// For custom intervals (e.g. 30s) we'd use setInterval instead.
if (POLL_SECONDS === 60) {
cron.schedule('* * * * *', pollTick); // every minute
console.log(`[main] Polling every 60s`);
} else {
// setInterval fallback for non-60s intervals
setInterval(pollTick, POLL_SECONDS * 1000);
console.log(`[main] Polling every ${POLL_SECONDS}s`);
}
// 6. Notify Telegram that service started
await sendServiceNotification('start');
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('[main] SIGTERM received, shutting down...');
await sendServiceNotification('stop');
process.exit(0);
});
}
main().catch(err => {
console.error('[main] Fatal error:', err);
process.exit(1);
});

34
src/notify/telegram.ts Normal file
View file

@ -0,0 +1,34 @@
import axios from 'axios';
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
/**
* Send a Telegram message with Markdown formatting.
* Silently logs on failure (don't crash the polling loop on a Telegram hiccup).
*/
export async function sendTelegram(text: string): Promise<void> {
if (!BOT_TOKEN || !CHAT_ID) {
console.warn('[telegram] BOT_TOKEN or CHAT_ID not set, skipping notification');
return;
}
try {
await axios.post(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
chat_id: CHAT_ID,
text,
parse_mode: 'Markdown',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[telegram] Send failed: ${msg}`);
}
}
/**
* Send a startup/shutdown notification
*/
export async function sendServiceNotification(event: 'start' | 'stop'): Promise<void> {
const emoji = event === 'start' ? '🚀' : '🛑';
await sendTelegram(`${emoji} *Position Tracker* ${event === 'start' ? 'started' : 'stopped'}`);
}

90
src/tracker/bands.ts Normal file
View file

@ -0,0 +1,90 @@
/**
* Band-crossing + continuous hybrid alert logic
*
* Rule:
* Fire alert when |currentPnl - anchorPnl| / |anchorPnl| >= THRESHOLD_PCT
* After firing: reset anchor = currentPnl (so next 5% is from the new level)
* Guard: if |anchorPnl| < MIN_ABS_INR, use absolute move check instead
* (prevents noise on near-flat positions like ±0.10 options)
*
* This is "continuous band-crossing": each alert resets the band origin,
* so a position moving 20% triggers 4 consecutive alerts (at 5%, 10%, 15%, 20%).
* It also re-alerts on reversals: if P&L drops back 5% after a gain alert, that fires too.
*/
export interface BandState {
positionKey: string;
anchorPnl: number; // P&L at last alert (or market-open if no alert today)
lastAlertPnl: number; // same as anchor after alert fires
lastAlertTime: Date | null;
tradingDate: string; // 'YYYY-MM-DD' IST — resets anchor daily at open
}
export interface AlertDecision {
shouldAlert: boolean;
deltaAbs: number; // ₹ move from anchor
deltaPct: number; // % move from anchor (signed)
direction: 'up' | 'down' | 'none';
}
const THRESHOLD_PCT = parseFloat(process.env.ALERT_THRESHOLD_PCT || '5') / 100;
const MIN_ABS_INR = parseFloat(process.env.ALERT_MIN_ABS_INR || '100');
/**
* Evaluate whether an alert should fire for a position.
* Does NOT mutate state caller updates DB after confirming alert sent.
*/
export function evaluateBand(
currentPnl: number,
state: BandState,
todayDate: string
): AlertDecision {
// Daily reset: if this is a new trading day, anchor resets to current P&L
// (caller is responsible for setting anchorPnl = currentPnl at market open)
const anchor = state.anchorPnl;
const deltaAbs = currentPnl - anchor;
const absDelta = Math.abs(deltaAbs);
const absAnchor = Math.abs(anchor);
let shouldAlert = false;
let deltaPct = 0;
if (absAnchor >= MIN_ABS_INR) {
// Normal case: anchor is meaningful, use % threshold
deltaPct = (deltaAbs / absAnchor) * 100;
shouldAlert = Math.abs(deltaPct) >= THRESHOLD_PCT * 100;
} else {
// Near-zero anchor: use absolute ₹ threshold only
deltaPct = 0; // can't compute meaningful %
shouldAlert = absDelta >= MIN_ABS_INR;
}
const direction = deltaAbs > 0 ? 'up' : deltaAbs < 0 ? 'down' : 'none';
return { shouldAlert, deltaAbs, deltaPct, direction };
}
/**
* Format a human-readable alert message for Telegram
*/
export function formatAlertMessage(
symbol: string,
currentPnl: number,
decision: AlertDecision,
anchorPnl: number,
netqty: number,
ltp: number
): string {
const sign = decision.direction === 'up' ? '🟢' : '🔴';
const pnlSign = currentPnl >= 0 ? '+' : '';
const pctStr = Math.abs(decision.deltaPct) > 0
? ` (${decision.deltaPct > 0 ? '+' : ''}${decision.deltaPct.toFixed(1)}%)`
: '';
return [
`${sign} *${symbol}*`,
`P&L: ₹${pnlSign}${currentPnl.toFixed(0)}${pctStr} from ₹${anchorPnl >= 0 ? '+' : ''}${anchorPnl.toFixed(0)}`,
`Move: ₹${decision.deltaAbs > 0 ? '+' : ''}${decision.deltaAbs.toFixed(0)}`,
`LTP: ₹${ltp.toFixed(2)} | Qty: ${netqty}`,
].join('\n');
}

View file

@ -0,0 +1,97 @@
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
const IST = 'Asia/Kolkata';
// NSE trading hours
const MARKET_OPEN_H = 9;
const MARKET_OPEN_M = 15;
const MARKET_CLOSE_H = 15;
const MARKET_CLOSE_M = 30;
/**
* NSE holidays 2025 (add 2026 when known)
* Format: 'YYYY-MM-DD' in IST date
*/
const NSE_HOLIDAYS_2025 = new Set([
'2025-01-26', // Republic Day
'2025-02-26', // Mahashivratri
'2025-03-14', // Holi
'2025-04-10', // Ram Navami
'2025-04-14', // Dr. Ambedkar Jayanti
'2025-04-18', // Good Friday
'2025-05-01', // Maharashtra Day
'2025-06-07', // Eid ul-Adha
'2025-07-18', // Muharram
'2025-08-15', // Independence Day
'2025-08-27', // Ganesh Chaturthi
'2025-10-02', // Gandhi Jayanti
'2025-10-21', // Diwali (Laxmi Puja)
'2025-10-22', // Diwali (Balipratipada)
'2025-10-28', // Diwali (Muhurat trading — special session, NOT regular)
'2025-11-05', // Prakash Gurpurb
'2025-12-25', // Christmas
]);
const NSE_HOLIDAYS_2026 = new Set([
'2026-01-26', // Republic Day
'2026-03-20', // Holi (tentative)
'2026-04-03', // Good Friday (tentative)
'2026-04-14', // Dr. Ambedkar Jayanti
'2026-05-01', // Maharashtra Day
'2026-08-15', // Independence Day
'2026-10-02', // Gandhi Jayanti
'2026-12-25', // Christmas
]);
function getISTDate(now: Date): { dateStr: string; h: number; m: number; dayOfWeek: number } {
const ist = toZonedTime(now, IST);
const dateStr = ist.toISOString().slice(0, 10); // 'YYYY-MM-DD'
return {
dateStr,
h: ist.getHours(),
m: ist.getMinutes(),
dayOfWeek: ist.getDay(), // 0=Sun, 6=Sat
};
}
/**
* Returns true if the market is currently open (NSE cash + F&O session).
* Skips weekends and NSE holidays.
*/
export function isMarketOpen(now: Date = new Date()): boolean {
const { dateStr, h, m, dayOfWeek } = getISTDate(now);
// Weekend
if (dayOfWeek === 0 || dayOfWeek === 6) return false;
// Holiday
if (NSE_HOLIDAYS_2025.has(dateStr) || NSE_HOLIDAYS_2026.has(dateStr)) return false;
// Time check: 09:15 to 15:30 IST
const minuteOfDay = h * 60 + m;
const openMinute = MARKET_OPEN_H * 60 + MARKET_OPEN_M;
const closeMinute = MARKET_CLOSE_H * 60 + MARKET_CLOSE_M;
return minuteOfDay >= openMinute && minuteOfDay < closeMinute;
}
/**
* Returns IST date string 'YYYY-MM-DD' used as daily reset key
*/
export function todayIST(now: Date = new Date()): string {
return getISTDate(now).dateStr;
}
/**
* Next market open as a UTC Date (useful for scheduling)
*/
export function nextMarketOpen(now: Date = new Date()): Date {
const { dayOfWeek } = getISTDate(now);
// Simple: return 09:16 IST tomorrow (or Monday if Friday/weekend)
// For our purposes we just poll every minute and check isMarketOpen() — no need for precise scheduling
const nextDay = new Date(now.getTime() + 24 * 60 * 60 * 1000);
return fromZonedTime(
new Date(toZonedTime(nextDay, IST).toISOString().slice(0, 10) + 'T09:16:00'),
IST
);
}

171
src/tracker/poll.ts Normal file
View file

@ -0,0 +1,171 @@
import { fetchAllPositions } from '../angel/client.js';
import { isMarketOpen, todayIST } from './market-hours.js';
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';
let isPolling = false;
/**
* Main polling tick called every POLL_INTERVAL_SECONDS by cron.
* Idempotent: skips if previous tick still running.
*/
export async function pollTick(): Promise<void> {
if (isPolling) {
console.log('[poll] Skipping tick: previous poll still running');
return;
}
if (!isMarketOpen()) {
// Log only once per 5 min to avoid log spam
const now = new Date();
if (now.getMinutes() % 5 === 0) {
console.log('[poll] Market closed, skipping poll');
}
return;
}
isPolling = true;
const today = todayIST();
try {
const positions = await fetchAllPositions();
// Upsert latest snapshot into positions table
const upsertPos = db.prepare(`
INSERT INTO positions (key, exchange, tradingsymbol, instrumenttype, producttype,
netqty, ltp, avg_price, unrealised_pnl, realised_pnl, total_pnl, source, updated_at)
VALUES (@key, @exchange, @tradingsymbol, @instrumenttype, @producttype,
@netqty, @ltp, @avg_price, @unrealised_pnl, @realised_pnl, @total_pnl, @source, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
netqty = excluded.netqty,
ltp = excluded.ltp,
avg_price = excluded.avg_price,
unrealised_pnl = excluded.unrealised_pnl,
realised_pnl = excluded.realised_pnl,
total_pnl = excluded.total_pnl,
updated_at = excluded.updated_at
`);
for (const pos of positions) {
upsertPos.run({
key: pos.key,
exchange: pos.exchange,
tradingsymbol: pos.tradingsymbol,
instrumenttype: pos.instrumenttype,
producttype: pos.producttype,
netqty: pos.netqty,
ltp: pos.ltp,
avg_price: pos.avgPrice,
unrealised_pnl: pos.unrealisedPnl,
realised_pnl: pos.realisedPnl,
total_pnl: pos.totalPnl,
source: pos.source,
});
}
// Mark positions that are no longer in response as closed
const activeKeys = positions.map(p => p.key);
if (activeKeys.length > 0) {
const placeholders = activeKeys.map(() => '?').join(',');
db.prepare(`UPDATE positions SET is_closed = 1, updated_at = datetime('now')
WHERE key NOT IN (${placeholders})`).run(...activeKeys);
} else {
db.prepare(`UPDATE positions SET is_closed = 1, updated_at = datetime('now')`).run();
}
// Evaluate band alerts for each open position
await evaluateAlerts(positions, today);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[poll] Error: ${msg}`);
// Record error in DB for health endpoint
db.prepare(`INSERT INTO poll_errors (error, occurred_at) VALUES (?, datetime('now'))`)
.run(msg);
} finally {
isPolling = false;
}
}
async function evaluateAlerts(positions: Position[], today: string): Promise<void> {
const getBandState = db.prepare<[string]>(
`SELECT * FROM band_state WHERE position_key = ?`
);
const upsertBandState = db.prepare(`
INSERT INTO band_state (position_key, anchor_pnl, last_alert_pnl, last_alert_time, trading_date)
VALUES (@position_key, @anchor_pnl, @last_alert_pnl, @last_alert_time, @trading_date)
ON CONFLICT(position_key) DO UPDATE SET
anchor_pnl = excluded.anchor_pnl,
last_alert_pnl = excluded.last_alert_pnl,
last_alert_time = excluded.last_alert_time,
trading_date = excluded.trading_date
`);
const insertAlert = db.prepare(`
INSERT INTO alerts (position_key, tradingsymbol, current_pnl, anchor_pnl,
delta_abs, delta_pct, direction, ltp, netqty, alerted_at)
VALUES (@position_key, @tradingsymbol, @current_pnl, @anchor_pnl,
@delta_abs, @delta_pct, @direction, @ltp, @netqty, datetime('now'))
`);
for (const pos of positions) {
let state = getBandState.get(pos.key) as BandState | undefined;
// First time seeing this position, or new trading day → initialise anchor
if (!state || state.tradingDate !== today) {
state = {
positionKey: pos.key,
anchorPnl: pos.totalPnl,
lastAlertPnl: pos.totalPnl,
lastAlertTime: null,
tradingDate: today,
};
upsertBandState.run({
position_key: pos.key,
anchor_pnl: pos.totalPnl,
last_alert_pnl: pos.totalPnl,
last_alert_time: null,
trading_date: today,
});
continue; // no alert on first seen (anchor just set)
}
const decision = evaluateBand(pos.totalPnl, state, today);
if (decision.shouldAlert) {
const msg = formatAlertMessage(
pos.tradingsymbol,
pos.totalPnl,
decision,
state.anchorPnl,
pos.netqty,
pos.ltp
);
await sendTelegram(msg);
// Persist alert history
insertAlert.run({
position_key: pos.key,
tradingsymbol: pos.tradingsymbol,
current_pnl: pos.totalPnl,
anchor_pnl: state.anchorPnl,
delta_abs: decision.deltaAbs,
delta_pct: decision.deltaPct,
direction: decision.direction,
ltp: pos.ltp,
netqty: pos.netqty,
});
// Reset anchor to current P&L (hybrid continuous band)
upsertBandState.run({
position_key: pos.key,
anchor_pnl: pos.totalPnl,
last_alert_pnl: pos.totalPnl,
last_alert_time: new Date().toISOString(),
trading_date: today,
});
}
}
}

16
tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}