fix: market cache in SQLite for after-hours; collapsible sections with localStorage state
This commit is contained in:
parent
1f00d2da41
commit
5bdf7fbffe
3 changed files with 91 additions and 78 deletions
|
|
@ -85,7 +85,13 @@
|
||||||
.btn-save{padding:8px 22px;border-radius:9px;background:var(--coral);border:none;color:#fff;font-size:.78rem;font-weight:600;cursor:pointer;transition:all var(--tr)}
|
.btn-save{padding:8px 22px;border-radius:9px;background:var(--coral);border:none;color:#fff;font-size:.78rem;font-weight:600;cursor:pointer;transition:all var(--tr)}
|
||||||
.btn-save:hover{background:#e55a3a;transform:translateY(-1px)}
|
.btn-save:hover{background:#e55a3a;transform:translateY(-1px)}
|
||||||
.save-msg{font-size:.72rem;color:var(--green);display:none;margin-left:10px}
|
.save-msg{font-size:.72rem;color:var(--green);display:none;margin-left:10px}
|
||||||
</style>
|
|
||||||
|
.collapsible .card-head{cursor:pointer;user-select:none}
|
||||||
|
.collapsible .card-head::after{content:"\u2303";font-size:.8rem;color:var(--text3);transition:transform .2s;margin-left:8px}
|
||||||
|
.collapsible.collapsed .card-head::after{transform:rotate(180deg)}
|
||||||
|
.collapse-body{overflow:hidden;transition:max-height .35s cubic-bezier(.4,0,.2,1);max-height:4000px}
|
||||||
|
.collapsible.collapsed .collapse-body{max-height:0}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
@ -130,11 +136,11 @@
|
||||||
<button class="rbtn" onclick="loadChart(72)" id="b72">3D</button>
|
<button class="rbtn" onclick="loadChart(72)" id="b72">3D</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrap"><canvas id="pnl-chart"></canvas><div class="chart-empty" id="chart-empty">No history yet — populates after first poll cycle</div></div>
|
<div class="collapse-body"><div class="chart-wrap"><canvas id="pnl-chart"></canvas><div class="chart-empty" id="chart-empty">No history yet — populates after first poll cycle</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:16px">
|
<div class="card" style="margin-bottom:16px">
|
||||||
<div class="card-head"><span class="card-title">Option Positions</span><div class="live-badge"><span class="ldot"></span>Live</div></div>
|
<div class="card-head"><span class="card-title">Option Positions</span><div class="live-badge"><span class="ldot"></span>Live</div></div>
|
||||||
<table>
|
<div class="collapse-body"><table>
|
||||||
<thead><tr><th>Symbol</th><th>Qty</th><th>LTP</th><th>Avg Cost</th><th>Unrealised</th><th>Realised</th><th>Total P&L</th></tr></thead>
|
<thead><tr><th>Symbol</th><th>Qty</th><th>LTP</th><th>Avg Cost</th><th>Unrealised</th><th>Realised</th><th>Total P&L</th></tr></thead>
|
||||||
<tbody id="pos-body"><tr><td colspan="7" style="text-align:center;padding:36px;color:var(--text3)">Loading…</td></tr></tbody>
|
<tbody id="pos-body"><tr><td colspan="7" style="text-align:center;padding:36px;color:var(--text3)">Loading…</td></tr></tbody>
|
||||||
<tfoot id="pos-foot" style="display:none"><tr><td colspan="4">Portfolio Total</td><td id="f-u" class="mono">—</td><td id="f-r" class="mono">—</td><td id="f-t" class="mono">—</td></tr></tfoot>
|
<tfoot id="pos-foot" style="display:none"><tr><td colspan="4">Portfolio Total</td><td id="f-u" class="mono">—</td><td id="f-r" class="mono">—</td><td id="f-t" class="mono">—</td></tr></tfoot>
|
||||||
|
|
@ -142,7 +148,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:16px">
|
<div class="card" style="margin-bottom:16px">
|
||||||
<div class="card-head"><span class="card-title">Alert History</span></div>
|
<div class="card-head"><span class="card-title">Alert History</span></div>
|
||||||
<table>
|
<div class="collapse-body"><table>
|
||||||
<thead><tr><th>Symbol</th><th>P&L at Alert</th><th>Anchor</th><th>Move</th><th>LTP</th><th>Time (IST)</th></tr></thead>
|
<thead><tr><th>Symbol</th><th>P&L at Alert</th><th>Anchor</th><th>Move</th><th>LTP</th><th>Time (IST)</th></tr></thead>
|
||||||
<tbody id="alrt-body"><tr><td colspan="6" style="text-align:center;padding:28px;color:var(--text3)">No alerts yet</td></tr></tbody>
|
<tbody id="alrt-body"><tr><td colspan="6" style="text-align:center;padding:28px;color:var(--text3)">No alerts yet</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -152,7 +158,7 @@
|
||||||
<span class="card-title">Alert Settings</span>
|
<span class="card-title">Alert Settings</span>
|
||||||
<span style="font-size:.68rem;color:var(--text3)">Changes apply immediately</span>
|
<span style="font-size:.68rem;color:var(--text3)">Changes apply immediately</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:16px 20px">
|
<div class="collapse-body"><div style="padding:16px 20px">
|
||||||
<div class="sg">
|
<div class="sg">
|
||||||
<div class="field"><label>Global Threshold %</label><input id="cfg-pct" type="number" min="1" max="50" step="0.5" value="5"/><span class="hint">Alert when P&L moves this % from anchor</span></div>
|
<div class="field"><label>Global Threshold %</label><input id="cfg-pct" type="number" min="1" max="50" step="0.5" value="5"/><span class="hint">Alert when P&L moves this % from anchor</span></div>
|
||||||
<div class="field"><label>Min Absolute Move (₹)</label><input id="cfg-abs" type="number" min="50" max="10000" step="50" value="100"/><span class="hint">Guard for near-zero P&L positions</span></div>
|
<div class="field"><label>Min Absolute Move (₹)</label><input id="cfg-abs" type="number" min="50" max="10000" step="50" value="100"/><span class="hint">Guard for near-zero P&L positions</span></div>
|
||||||
|
|
@ -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})});
|
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()]);}
|
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();
|
loadConfig();refresh();
|
||||||
setInterval(refresh,60000);
|
setInterval(refresh,60000);
|
||||||
setInterval(loadMarket,15000);
|
setInterval(loadMarket,15000);
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,86 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getAuthHeaders } from './auth.js';
|
import { getAuthHeaders } from './auth.js';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
|
||||||
const BASE = 'https://apiconnect.angelbroking.com';
|
const BASE = 'https://apiconnect.angelbroking.com';
|
||||||
|
|
||||||
export interface MarketQuote {
|
export interface MarketQuote {
|
||||||
key: string;
|
key: string; label: string; price: number;
|
||||||
label: string;
|
change: number; changePct: number; unit: string;
|
||||||
price: number;
|
stale: boolean; cachedAt?: string;
|
||||||
change: number;
|
|
||||||
changePct: number;
|
|
||||||
unit: string;
|
|
||||||
stale: boolean; // true = after-hours data from Yahoo fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed Angel tokens for major indices
|
|
||||||
const INDEX_TOKENS = [
|
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: 'NIFTY50', exchange: 'NSE', token: '26000', label: 'NIFTY 50', unit: 'pts' },
|
||||||
{ key: 'BANKNIFTY', exchange: 'NSE', token: '26009', label: 'BANK NIFTY', unit: 'pts' },
|
{ key: 'BANKNIFTY', exchange: 'NSE', token: '26009', label: 'BANK NIFTY', unit: 'pts' },
|
||||||
{ key: 'INDIAVIX', exchange: 'NSE', token: '26017', label: 'INDIA VIX', unit: '%' },
|
{ 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)
|
async function searchToken(exchange: string, query: string): Promise<string | null> {
|
||||||
const YAHOO_SYMBOLS: Record<string, { label: string; unit: string; yahooSym: string }> = {
|
try {
|
||||||
NIFTY50: { label: 'NIFTY 50', unit: 'pts', yahooSym: '^NSEI' },
|
const headers = await getAuthHeaders();
|
||||||
BANKNIFTY: { label: 'BANK NIFTY', unit: 'pts', yahooSym: '^NSEBANK' },
|
const r = await axios.post(BASE + '/rest/secure/angelbroking/order/v1/searchScrip',
|
||||||
INDIAVIX: { label: 'INDIA VIX', unit: '%', yahooSym: '^INDIAVIX' },
|
{ exchange, searchscrip: query }, { headers, timeout: 5000 });
|
||||||
SENSEX: { label: 'SENSEX', unit: 'pts', yahooSym: '^BSESN' },
|
return r.data?.data?.[0]?.symboltoken ?? null;
|
||||||
CRUDEOIL: { label: 'Crude Oil', unit: '\u20b9/bbl', yahooSym: 'CL=F' },
|
} catch { return null; }
|
||||||
USDINR: { label: 'INR/USD', unit: '\u20b9', yahooSym: 'USDINR=X' },
|
}
|
||||||
};
|
|
||||||
|
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<MarketQuote[]> {
|
async function fetchFromAngel(): Promise<MarketQuote[]> {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
|
|
||||||
// Step 1: search for front-month crude and INR/USD tokens
|
|
||||||
async function search(exchange: string, query: string): Promise<string | null> {
|
|
||||||
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([
|
const [crudeToken, usdinrToken] = await Promise.all([
|
||||||
search('MCX', 'CRUDEOIL'),
|
searchToken('MCX', 'CRUDEOIL'),
|
||||||
search('CDS', 'USDINR'),
|
searchToken('CDS', 'USDINR'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Step 2: build token map
|
|
||||||
const tokenMap: Record<string, string[]> = { NSE: [], BSE: [] };
|
const tokenMap: Record<string, string[]> = { NSE: [], BSE: [] };
|
||||||
const tokenIndex: Record<string, string> = {};
|
const tokenIndex: Record<string, string> = {};
|
||||||
|
|
||||||
for (const idx of INDEX_TOKENS) {
|
for (const idx of INDEX_TOKENS) {
|
||||||
tokenMap[idx.exchange] = tokenMap[idx.exchange] || [];
|
|
||||||
tokenMap[idx.exchange].push(idx.token);
|
tokenMap[idx.exchange].push(idx.token);
|
||||||
tokenIndex[idx.token] = idx.key;
|
tokenIndex[idx.token] = idx.key;
|
||||||
}
|
}
|
||||||
if (crudeToken) { tokenMap['MCX'] = [crudeToken]; tokenIndex[crudeToken] = 'CRUDEOIL'; }
|
if (crudeToken) { tokenMap['MCX'] = [crudeToken]; tokenIndex[crudeToken] = 'CRUDEOIL'; }
|
||||||
if (usdinrToken) { tokenMap['CDS'] = [usdinrToken]; tokenIndex[usdinrToken] = 'USDINR'; }
|
if (usdinrToken) { tokenMap['CDS'] = [usdinrToken]; tokenIndex[usdinrToken] = 'USDINR'; }
|
||||||
|
|
||||||
// Step 3: fetch quotes
|
const r = await axios.post(BASE + '/rest/secure/angelbroking/market/v1/quote/',
|
||||||
const r = await axios.post(
|
{ mode: 'FULL', exchangeTokens: tokenMap }, { headers, timeout: 8000 });
|
||||||
BASE + '/rest/secure/angelbroking/market/v1/quote/',
|
|
||||||
{ mode: 'FULL', exchangeTokens: tokenMap },
|
|
||||||
{ headers, timeout: 8000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetched: any[] = r.data?.data?.fetched ?? [];
|
const fetched: any[] = r.data?.data?.fetched ?? [];
|
||||||
if (!fetched.length) return [];
|
if (!fetched.length) return [];
|
||||||
|
|
||||||
|
const LABELS: Record<string, { label: string; unit: string }> = {
|
||||||
|
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) => {
|
return fetched.map((q: any) => {
|
||||||
const key = tokenIndex[q.symbolToken] ?? q.tradingSymbol;
|
const key = tokenIndex[q.symbolToken] ?? q.tradingSymbol;
|
||||||
const meta = YAHOO_SYMBOLS[key] ?? { label: q.tradingSymbol, unit: '' };
|
const meta = LABELS[key] ?? { label: q.tradingSymbol, unit: '' };
|
||||||
const ltp = parseFloat(q.ltp) || 0;
|
const ltp = parseFloat(q.ltp) || 0;
|
||||||
const close = parseFloat(q.close) || 0;
|
const close = parseFloat(q.close) || 0;
|
||||||
const change = parseFloat(q.netChange) || (ltp - close);
|
const change = parseFloat(q.netChange) || (ltp - close);
|
||||||
|
|
@ -84,38 +89,17 @@ async function fetchFromAngel(): Promise<MarketQuote[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromYahoo(): Promise<MarketQuote[]> {
|
|
||||||
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<string, string> = {};
|
|
||||||
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<MarketQuote[]> {
|
export async function fetchMarketData(): Promise<MarketQuote[]> {
|
||||||
try {
|
try {
|
||||||
const angelData = await fetchFromAngel();
|
const live = await fetchFromAngel();
|
||||||
if (angelData.length > 0) return angelData;
|
if (live.length > 0) {
|
||||||
// Angel returned empty (market closed) — fall back to Yahoo
|
saveToCache(live); // update cache whenever we get fresh data
|
||||||
return await fetchFromYahoo();
|
return live;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Angel auth/network error — try Yahoo
|
console.error('[market] Angel fetch error:', err instanceof Error ? err.message : err);
|
||||||
try { return await fetchFromYahoo(); }
|
|
||||||
catch { return []; }
|
|
||||||
}
|
}
|
||||||
|
// Market closed or error — serve cached data
|
||||||
|
const cached = loadFromCache();
|
||||||
|
return cached; // empty array if never cached (first run before market open)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ export function initDb(): void {
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_alerts_position ON alerts(position_key);
|
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_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);
|
CREATE INDEX IF NOT EXISTS idx_positions_closed ON positions(is_closed);
|
||||||
|
|
||||||
-- Portfolio-level P&L snapshots (recorded every poll tick)
|
-- Portfolio-level P&L snapshots (recorded every poll tick)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue