commit 971c59df1104bd2f713e4ca5cd566ee8e1e61186 Author: Manohar Date: Fri May 8 11:22:05 2026 +0000 feat: initial position tracker scaffold diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0992366 --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8aa35a3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4017ae2 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d1dc05 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..39fae64 --- /dev/null +++ b/public/index.html @@ -0,0 +1,200 @@ + + + + + + Position Tracker + + + + + + +
+
+

📊 Position Tracker

+

Loading...

+
+
+ + +
+
+ + +
+

Total P&L

+

Open Positions

+

Alerts Today

+

Last Error

None

+
+ + +
+
+

Open Positions

+
+ Live +
+
+
+ + + + + + + + + + + + + + + + +
SymbolQtyLTPAvgUnrealised P&LRealisedTotal P&LType
Loading...
+
+
+ + +
+
+

Recent Alerts

+
+
+ + + + + + + + + + + + + + +
SymbolP&L at AlertFrom AnchorMoveLTPTime (IST)
Loading...
+
+
+ + + + diff --git a/src/angel/auth.ts b/src/angel/auth.ts new file mode 100644 index 0000000..f685c30 --- /dev/null +++ b/src/angel/auth.ts @@ -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 { + 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( + `${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 { + 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> { + 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(), + }; +} diff --git a/src/angel/client.ts b/src/angel/client.ts new file mode 100644 index 0000000..6141c05 --- /dev/null +++ b/src/angel/client.ts @@ -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 { + const headers = await getAuthHeaders(); + const response = await axios.get( + `${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 { + const headers = await getAuthHeaders(); + const response = await axios.get( + `${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 { + const [rawPositions, rawHoldings] = await Promise.all([ + fetchPositions(), + fetchHoldings(), + ]); + + const map = new Map(); + + // 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()); +} diff --git a/src/angel/types.ts b/src/angel/types.ts new file mode 100644 index 0000000..a78d08e --- /dev/null +++ b/src/angel/types.ts @@ -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'; +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..adb3b98 --- /dev/null +++ b/src/api/server.ts @@ -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; +} diff --git a/src/db/client.ts b/src/db/client.ts new file mode 100644 index 0000000..609b7a1 --- /dev/null +++ b/src/db/client.ts @@ -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}`); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..510fd65 --- /dev/null +++ b/src/index.ts @@ -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); +}); diff --git a/src/notify/telegram.ts b/src/notify/telegram.ts new file mode 100644 index 0000000..62fc3ce --- /dev/null +++ b/src/notify/telegram.ts @@ -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 { + 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 { + const emoji = event === 'start' ? '🚀' : '🛑'; + await sendTelegram(`${emoji} *Position Tracker* ${event === 'start' ? 'started' : 'stopped'}`); +} diff --git a/src/tracker/bands.ts b/src/tracker/bands.ts new file mode 100644 index 0000000..da0a524 --- /dev/null +++ b/src/tracker/bands.ts @@ -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'); +} diff --git a/src/tracker/market-hours.ts b/src/tracker/market-hours.ts new file mode 100644 index 0000000..2dc409e --- /dev/null +++ b/src/tracker/market-hours.ts @@ -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 + ); +} diff --git a/src/tracker/poll.ts b/src/tracker/poll.ts new file mode 100644 index 0000000..69ec1a0 --- /dev/null +++ b/src/tracker/poll.ts @@ -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 { + 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 { + 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, + }); + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fa8ee32 --- /dev/null +++ b/tsconfig.json @@ -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"] +}