Alert when P&L moves this % from anchor
Guard for near-zero P&L positions
@@ -253,6 +259,18 @@ async function saveOverride(key){
await fetch('/api/config/'+encodeURIComponent(key),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({alert_threshold_pct:pct?parseFloat(pct):null,muted_until:mute?new Date(mute).toISOString():null})});
}
async function refresh(){await Promise.all([loadPositions(),loadAlerts(),loadHealth(),loadChart(curH),loadMarket()]);}
+
+function toggleCard(id){
+ const el=document.getElementById(id);
+ el.classList.toggle('collapsed');
+ const s=JSON.parse(localStorage.getItem('collapse')||'{}');
+ s[id]=el.classList.contains('collapsed');
+ localStorage.setItem('collapse',JSON.stringify(s));
+}
+(function(){
+ const s=JSON.parse(localStorage.getItem('collapse')||'{}');
+ Object.entries(s).forEach(([id,c])=>{if(c)document.getElementById(id)?.classList.add('collapsed')});
+})();
loadConfig();refresh();
setInterval(refresh,60000);
setInterval(loadMarket,15000);
diff --git a/src/angel/market.ts b/src/angel/market.ts
index 304739e..68882e9 100644
--- a/src/angel/market.ts
+++ b/src/angel/market.ts
@@ -1,81 +1,86 @@
import axios from 'axios';
import { getAuthHeaders } from './auth.js';
+import { db } from '../db/client.js';
const BASE = 'https://apiconnect.angelbroking.com';
export interface MarketQuote {
- key: string;
- label: string;
- price: number;
- change: number;
- changePct: number;
- unit: string;
- stale: boolean; // true = after-hours data from Yahoo fallback
+ key: string; label: string; price: number;
+ change: number; changePct: number; unit: string;
+ stale: boolean; cachedAt?: string;
}
-// Fixed Angel tokens for major indices
const INDEX_TOKENS = [
+ { key: 'SENSEX', exchange: 'BSE', token: '1', label: 'SENSEX', unit: 'pts' },
{ key: 'NIFTY50', exchange: 'NSE', token: '26000', label: 'NIFTY 50', unit: 'pts' },
{ key: 'BANKNIFTY', exchange: 'NSE', token: '26009', label: 'BANK NIFTY', unit: 'pts' },
{ key: 'INDIAVIX', exchange: 'NSE', token: '26017', label: 'INDIA VIX', unit: '%' },
- { key: 'SENSEX', exchange: 'BSE', token: '1', label: 'SENSEX', unit: 'pts' },
];
-// Yahoo Finance symbols for fallback (after hours / Angel empty)
-const YAHOO_SYMBOLS: Record
= {
- NIFTY50: { label: 'NIFTY 50', unit: 'pts', yahooSym: '^NSEI' },
- BANKNIFTY: { label: 'BANK NIFTY', unit: 'pts', yahooSym: '^NSEBANK' },
- INDIAVIX: { label: 'INDIA VIX', unit: '%', yahooSym: '^INDIAVIX' },
- SENSEX: { label: 'SENSEX', unit: 'pts', yahooSym: '^BSESN' },
- CRUDEOIL: { label: 'Crude Oil', unit: '\u20b9/bbl', yahooSym: 'CL=F' },
- USDINR: { label: 'INR/USD', unit: '\u20b9', yahooSym: 'USDINR=X' },
-};
+async function searchToken(exchange: string, query: string): Promise {
+ try {
+ const headers = await getAuthHeaders();
+ const r = await axios.post(BASE + '/rest/secure/angelbroking/order/v1/searchScrip',
+ { exchange, searchscrip: query }, { headers, timeout: 5000 });
+ return r.data?.data?.[0]?.symboltoken ?? null;
+ } catch { return null; }
+}
+
+function saveToCache(quotes: MarketQuote[]): void {
+ const upsert = db.prepare(`INSERT INTO market_cache (key,label,price,change_val,change_pct,unit,cached_at)
+ VALUES (@key,@label,@price,@change_val,@change_pct,@unit,datetime('now'))
+ ON CONFLICT(key) DO UPDATE SET label=excluded.label,price=excluded.price,
+ change_val=excluded.change_val,change_pct=excluded.change_pct,unit=excluded.unit,cached_at=excluded.cached_at`);
+ for (const q of quotes) {
+ upsert.run({ key: q.key, label: q.label, price: q.price,
+ change_val: q.change, change_pct: q.changePct, unit: q.unit });
+ }
+}
+
+function loadFromCache(): MarketQuote[] {
+ const rows = db.prepare('SELECT * FROM market_cache ORDER BY key').all() as any[];
+ return rows.map(r => ({
+ key: r.key, label: r.label, price: r.price,
+ change: r.change_val, changePct: r.change_pct, unit: r.unit,
+ stale: true, cachedAt: r.cached_at,
+ }));
+}
async function fetchFromAngel(): Promise {
const headers = await getAuthHeaders();
- // Step 1: search for front-month crude and INR/USD tokens
- async function search(exchange: string, query: string): Promise {
- try {
- const r = await axios.post(
- BASE + '/rest/secure/angelbroking/order/v1/searchScrip',
- { exchange, searchscrip: query },
- { headers, timeout: 5000 }
- );
- return r.data?.data?.[0]?.symboltoken ?? null;
- } catch { return null; }
- }
-
const [crudeToken, usdinrToken] = await Promise.all([
- search('MCX', 'CRUDEOIL'),
- search('CDS', 'USDINR'),
+ searchToken('MCX', 'CRUDEOIL'),
+ searchToken('CDS', 'USDINR'),
]);
- // Step 2: build token map
const tokenMap: Record = { NSE: [], BSE: [] };
const tokenIndex: Record = {};
-
for (const idx of INDEX_TOKENS) {
- tokenMap[idx.exchange] = tokenMap[idx.exchange] || [];
tokenMap[idx.exchange].push(idx.token);
tokenIndex[idx.token] = idx.key;
}
if (crudeToken) { tokenMap['MCX'] = [crudeToken]; tokenIndex[crudeToken] = 'CRUDEOIL'; }
if (usdinrToken) { tokenMap['CDS'] = [usdinrToken]; tokenIndex[usdinrToken] = 'USDINR'; }
- // Step 3: fetch quotes
- const r = await axios.post(
- BASE + '/rest/secure/angelbroking/market/v1/quote/',
- { mode: 'FULL', exchangeTokens: tokenMap },
- { headers, timeout: 8000 }
- );
+ const r = await axios.post(BASE + '/rest/secure/angelbroking/market/v1/quote/',
+ { mode: 'FULL', exchangeTokens: tokenMap }, { headers, timeout: 8000 });
const fetched: any[] = r.data?.data?.fetched ?? [];
if (!fetched.length) return [];
+ const LABELS: Record = {
+ SENSEX: { label: 'SENSEX', unit: 'pts' },
+ NIFTY50: { label: 'NIFTY 50', unit: 'pts' },
+ BANKNIFTY: { label: 'BANK NIFTY', unit: 'pts' },
+ INDIAVIX: { label: 'INDIA VIX', unit: '%' },
+ CRUDEOIL: { label: 'Crude Oil', unit: '\u20b9/bbl' },
+ USDINR: { label: 'INR/USD', unit: '\u20b9' },
+ };
+
return fetched.map((q: any) => {
- const key = tokenIndex[q.symbolToken] ?? q.tradingSymbol;
- const meta = YAHOO_SYMBOLS[key] ?? { label: q.tradingSymbol, unit: '' };
+ const key = tokenIndex[q.symbolToken] ?? q.tradingSymbol;
+ const meta = LABELS[key] ?? { label: q.tradingSymbol, unit: '' };
const ltp = parseFloat(q.ltp) || 0;
const close = parseFloat(q.close) || 0;
const change = parseFloat(q.netChange) || (ltp - close);
@@ -84,38 +89,17 @@ async function fetchFromAngel(): Promise {
});
}
-async function fetchFromYahoo(): Promise {
- const syms = Object.entries(YAHOO_SYMBOLS).map(([k, v]) => v.yahooSym).join(',');
- const url = 'https://query1.finance.yahoo.com/v7/finance/quote?symbols=' + syms;
- const r = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: 8000 });
- const quotes: any[] = r.data?.quoteResponse?.result ?? [];
-
- const yahooKeyMap: Record = {};
- for (const [k, v] of Object.entries(YAHOO_SYMBOLS)) yahooKeyMap[v.yahooSym] = k;
-
- return quotes.map((q: any) => {
- const key = yahooKeyMap[q.symbol] ?? q.symbol;
- const meta = YAHOO_SYMBOLS[key] ?? { label: q.shortName ?? key, unit: '' };
- return {
- key, label: meta.label,
- price: q.regularMarketPrice ?? 0,
- change: q.regularMarketChange ?? 0,
- changePct: q.regularMarketChangePercent ?? 0,
- unit: meta.unit,
- stale: true, // Yahoo = previous session close after hours
- };
- });
-}
-
export async function fetchMarketData(): Promise {
try {
- const angelData = await fetchFromAngel();
- if (angelData.length > 0) return angelData;
- // Angel returned empty (market closed) — fall back to Yahoo
- return await fetchFromYahoo();
+ const live = await fetchFromAngel();
+ if (live.length > 0) {
+ saveToCache(live); // update cache whenever we get fresh data
+ return live;
+ }
} catch (err) {
- // Angel auth/network error — try Yahoo
- try { return await fetchFromYahoo(); }
- catch { return []; }
+ console.error('[market] Angel fetch error:', err instanceof Error ? err.message : err);
}
+ // Market closed or error — serve cached data
+ const cached = loadFromCache();
+ return cached; // empty array if never cached (first run before market open)
}
diff --git a/src/db/client.ts b/src/db/client.ts
index 1157407..7512d10 100644
--- a/src/db/client.ts
+++ b/src/db/client.ts
@@ -78,6 +78,17 @@ export function initDb(): void {
-- 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);
+
+ -- Market data cache (persists last known values for after-hours display)
+ CREATE TABLE IF NOT EXISTS market_cache (
+ key TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ price REAL NOT NULL,
+ change_val REAL NOT NULL,
+ change_pct REAL NOT NULL,
+ unit TEXT NOT NULL,
+ cached_at TEXT NOT NULL
+ );
CREATE INDEX IF NOT EXISTS idx_positions_closed ON positions(is_closed);
-- Portfolio-level P&L snapshots (recorded every poll tick)