feat: initial position tracker scaffold
This commit is contained in:
commit
971c59df11
16 changed files with 1343 additions and 0 deletions
18
.env.example
Normal file
18
.env.example
Normal 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
42
Dockerfile
Normal 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
26
docker-compose.yml
Normal 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
28
package.json
Normal 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
200
public/index.html
Normal 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
123
src/angel/auth.ts
Normal 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
133
src/angel/client.ts
Normal 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
119
src/angel/types.ts
Normal 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
105
src/api/server.ts
Normal 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
85
src/db/client.ts
Normal 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
56
src/index.ts
Normal 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
34
src/notify/telegram.ts
Normal 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
90
src/tracker/bands.ts
Normal 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');
|
||||
}
|
||||
97
src/tracker/market-hours.ts
Normal file
97
src/tracker/market-hours.ts
Normal 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
171
src/tracker/poll.ts
Normal 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
16
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue