Spaces:
Sleeping
Sleeping
| import http from "node:http"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| const publicDir = path.join(__dirname, "public"); | |
| const seedPath = path.join(__dirname, "data", "seed-data.json"); | |
| const cachePath = path.join(__dirname, "data", "dashboard-cache.json"); | |
| const breadthHistoryPath = path.join(__dirname, "data", "breadth-history.json"); | |
| const port = Number(process.env.PORT || 4173); | |
| const host = process.env.HOST || (process.env.SPACE_ID ? "0.0.0.0" : "127.0.0.1"); | |
| const seedOnly = process.env.GEN_DASHBOARD_SEED_ONLY === "1"; | |
| const quoteUrl = "https://query1.finance.yahoo.com/v7/finance/quote"; | |
| const chartUrl = "https://query1.finance.yahoo.com/v8/finance/chart"; | |
| const stooqQuoteUrl = "https://stooq.com/q/l/"; | |
| let memoryCache = null; | |
| function nyDate(date = new Date()) { | |
| const parts = new Intl.DateTimeFormat("en-US", { | |
| timeZone: "America/New_York", | |
| year: "numeric", | |
| month: "2-digit", | |
| day: "2-digit", | |
| }).formatToParts(date); | |
| const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])); | |
| return `${byType.year}-${byType.month}-${byType.day}`; | |
| } | |
| function isoDate(date) { | |
| return date.toISOString().slice(0, 10); | |
| } | |
| function toYahooSymbol(symbol) { | |
| if (!symbol) return ""; | |
| if (symbol === "NDX") return "^NDX"; | |
| if (symbol === "VIX") return "^VIX"; | |
| return String(symbol).replace(".", "-").trim(); | |
| } | |
| function toStooqSymbol(symbol) { | |
| if (!symbol || String(symbol).startsWith("^") || symbol === "BTC-USD") return null; | |
| return `${String(symbol).replace(".", "-").toLowerCase()}.us`; | |
| } | |
| function percentFromQuote(value) { | |
| return Number.isFinite(value) ? value / 100 : null; | |
| } | |
| function number(value) { | |
| return Number.isFinite(value) ? value : null; | |
| } | |
| function uniq(values) { | |
| return [...new Set(values.filter(Boolean))]; | |
| } | |
| function chunk(values, size) { | |
| const groups = []; | |
| for (let i = 0; i < values.length; i += size) groups.push(values.slice(i, i + size)); | |
| return groups; | |
| } | |
| async function readJson(filePath, fallback = null) { | |
| try { | |
| return JSON.parse(await fs.readFile(filePath, "utf8")); | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| async function writeJson(filePath, data) { | |
| await fs.mkdir(path.dirname(filePath), { recursive: true }); | |
| await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); | |
| } | |
| async function fetchJson(url, timeoutMs = 9000) { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), timeoutMs); | |
| try { | |
| const response = await fetch(url, { | |
| signal: controller.signal, | |
| headers: { | |
| "user-agent": | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36", | |
| accept: "application/json,text/plain,*/*", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | |
| return await response.json(); | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } | |
| async function fetchYahooQuotes(symbols) { | |
| if (!symbols.length) return new Map(); | |
| const groups = chunk(uniq(symbols).map(toYahooSymbol), 80); | |
| const results = await Promise.allSettled( | |
| groups.map(async (group) => { | |
| const url = `${quoteUrl}?symbols=${encodeURIComponent(group.join(","))}`; | |
| const json = await fetchJson(url); | |
| return json?.quoteResponse?.result || []; | |
| }), | |
| ); | |
| const quotes = new Map(); | |
| for (const result of results) { | |
| if (result.status !== "fulfilled") continue; | |
| for (const quote of result.value) { | |
| quotes.set(quote.symbol, quote); | |
| } | |
| } | |
| return quotes; | |
| } | |
| function parseCsv(text) { | |
| return text | |
| .trim() | |
| .split(/\r?\n/) | |
| .map((line) => line.split(",").map((cell) => cell.trim())); | |
| } | |
| function toFinite(value) { | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : null; | |
| } | |
| async function fetchStooqQuotes(symbols) { | |
| const symbolMap = new Map(); | |
| for (const symbol of symbols) { | |
| const stooq = toStooqSymbol(symbol); | |
| if (stooq) symbolMap.set(stooq.toUpperCase(), toYahooSymbol(symbol)); | |
| } | |
| if (!symbolMap.size) return new Map(); | |
| const groups = chunk([...symbolMap.keys()].map((symbol) => symbol.toLowerCase()), 80); | |
| const results = await Promise.allSettled( | |
| groups.map(async (group) => { | |
| const params = new URLSearchParams({ s: group.join(" "), f: "sd2t2ohlcvp", h: "", e: "csv" }); | |
| const text = await fetch(`${stooqQuoteUrl}?${params.toString()}`, { | |
| headers: { "user-agent": "Mozilla/5.0", accept: "text/csv,*/*" }, | |
| }).then((response) => { | |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | |
| return response.text(); | |
| }); | |
| return parseCsv(text).slice(1); | |
| }), | |
| ); | |
| const quotes = new Map(); | |
| for (const result of results) { | |
| if (result.status !== "fulfilled") continue; | |
| for (const row of result.value) { | |
| const [stooqSymbol, date, , , , , closeRaw, volumeRaw, prevRaw] = row; | |
| if (!stooqSymbol || date === "N/D") continue; | |
| const symbol = symbolMap.get(stooqSymbol.toUpperCase()); | |
| const close = toFinite(closeRaw); | |
| const previous = toFinite(prevRaw); | |
| const volume = toFinite(volumeRaw); | |
| if (!symbol || !Number.isFinite(close)) continue; | |
| quotes.set(symbol, { | |
| symbol, | |
| regularMarketPrice: close, | |
| regularMarketPreviousClose: previous, | |
| regularMarketChangePercent: | |
| Number.isFinite(previous) && previous !== 0 ? ((close - previous) / previous) * 100 : null, | |
| regularMarketVolume: volume, | |
| source: "stooq", | |
| }); | |
| } | |
| } | |
| return quotes; | |
| } | |
| async function fetchQuotes(symbols) { | |
| const wanted = uniq(symbols); | |
| const yahooQuotes = await fetchYahooQuotes(wanted).catch(() => new Map()); | |
| const missing = wanted.filter((symbol) => !yahooQuotes.has(toYahooSymbol(symbol))); | |
| if (missing.length) { | |
| const stooqQuotes = await fetchStooqQuotes(missing).catch(() => new Map()); | |
| for (const [symbol, quote] of stooqQuotes) yahooQuotes.set(symbol, quote); | |
| } | |
| return yahooQuotes; | |
| } | |
| async function fetchChart(symbol, { range, interval = "1d", period1, period2 } = {}) { | |
| const params = new URLSearchParams({ interval }); | |
| if (range) { | |
| params.set("range", range); | |
| } else { | |
| params.set("period1", String(period1)); | |
| params.set("period2", String(period2 || Math.floor(Date.now() / 1000))); | |
| } | |
| const json = await fetchJson(`${chartUrl}/${encodeURIComponent(toYahooSymbol(symbol))}?${params.toString()}`); | |
| const result = json?.chart?.result?.[0]; | |
| if (!result?.timestamp?.length) return null; | |
| const quote = result.indicators?.quote?.[0] || {}; | |
| const closes = quote.close || []; | |
| const volumes = quote.volume || []; | |
| return result.timestamp | |
| .map((timestamp, index) => ({ | |
| date: isoDate(new Date(timestamp * 1000)), | |
| close: number(closes[index]), | |
| volume: number(volumes[index]), | |
| })) | |
| .filter((point) => point.close !== null); | |
| } | |
| function quoteLookup(quotes, symbol) { | |
| return quotes.get(toYahooSymbol(symbol)) || null; | |
| } | |
| function quoteChange(quotes, symbol) { | |
| return percentFromQuote(quoteLookup(quotes, symbol)?.regularMarketChangePercent); | |
| } | |
| function quotePrice(quotes, symbol) { | |
| return number(quoteLookup(quotes, symbol)?.regularMarketPrice); | |
| } | |
| function enrichHolding(holding, quotes, snapshot = null) { | |
| const quote = quoteLookup(quotes, holding.ticker); | |
| const volume = number(quote?.regularMarketVolume); | |
| const avgVolume = number(quote?.averageDailyVolume3Month) || number(quote?.averageDailyVolume10Day); | |
| return { | |
| ...holding, | |
| sector: holding.sector ?? snapshot?.sector ?? null, | |
| price: number(quote?.regularMarketPrice) ?? snapshot?.price ?? null, | |
| change: percentFromQuote(quote?.regularMarketChangePercent) ?? snapshot?.change ?? null, | |
| range: snapshot?.range ?? holding.range ?? null, | |
| volume: volume ?? null, | |
| avgVolume: avgVolume ?? null, | |
| volumeRatio: Number.isFinite(volume) && Number.isFinite(avgVolume) && avgVolume ? volume / avgVolume : null, | |
| fiftyTwoWeekHigh: number(quote?.fiftyTwoWeekHigh), | |
| fiftyTwoWeekLow: number(quote?.fiftyTwoWeekLow), | |
| }; | |
| } | |
| function breadth(holdings) { | |
| const tracked = holdings.filter((item) => Number.isFinite(item.change)); | |
| const advance = tracked.filter((item) => item.change > 0).length; | |
| const decline = tracked.filter((item) => item.change < 0).length; | |
| const unchanged = tracked.filter((item) => item.change === 0).length; | |
| return { advance, decline, unchanged, total: tracked.length || holdings.length }; | |
| } | |
| function topMovers(holdings) { | |
| const valid = holdings.filter((item) => Number.isFinite(item.change)); | |
| return { | |
| losers: [...valid].sort((a, b) => a.change - b.change).slice(0, 10), | |
| gainers: [...valid].sort((a, b) => b.change - a.change).slice(0, 10), | |
| }; | |
| } | |
| function topRangeRows(holdings) { | |
| return holdings | |
| .map((item) => { | |
| const high = item.fiftyTwoWeekHigh; | |
| const low = item.fiftyTwoWeekLow; | |
| let range = item.range; | |
| if (Number.isFinite(item.price) && Number.isFinite(high) && Number.isFinite(low) && high !== low) { | |
| range = (item.price - low) / (high - low); | |
| } | |
| return { ...item, range }; | |
| }) | |
| .filter((item) => item.ticker && Number.isFinite(item.range)) | |
| .sort((a, b) => (b.weight || 0) - (a.weight || 0)) | |
| .slice(0, 18); | |
| } | |
| function topContributions(holdings) { | |
| return holdings | |
| .filter((item) => Number.isFinite(item.weight) && Number.isFinite(item.change)) | |
| .sort((a, b) => b.weight - a.weight) | |
| .slice(0, 10) | |
| .map((item) => ({ ...item, contribution: item.weight * item.change })); | |
| } | |
| function buildRangeDistribution(holdings) { | |
| const bins = [ | |
| { label: "0-20%", min: 0, max: 0.2 }, | |
| { label: "20-40%", min: 0.2, max: 0.4 }, | |
| { label: "40-60%", min: 0.4, max: 0.6 }, | |
| { label: "60-80%", min: 0.6, max: 0.8 }, | |
| { label: "80-100%", min: 0.8, max: 1.01 }, | |
| ].map((bin) => ({ ...bin, count: 0, weight: 0 })); | |
| for (const holding of holdings) { | |
| const range = Number(holding.range); | |
| if (!Number.isFinite(range)) continue; | |
| const bin = bins.find((item) => range >= item.min && range < item.max); | |
| if (!bin) continue; | |
| bin.count += 1; | |
| bin.weight += Number.isFinite(holding.weight) ? holding.weight : 0; | |
| } | |
| return bins.map(({ label, count, weight }) => ({ label, count, weight })); | |
| } | |
| function buildLeadershipMap(holdings) { | |
| return holdings | |
| .filter((item) => item.ticker && Number.isFinite(item.range) && Number.isFinite(item.change)) | |
| .map((item) => ({ | |
| ticker: item.ticker, | |
| range: item.range, | |
| change: item.change, | |
| weight: Number.isFinite(item.weight) ? item.weight : 0, | |
| sector: item.sector || "", | |
| price: Number.isFinite(item.price) ? item.price : null, | |
| })) | |
| .sort((a, b) => b.weight - a.weight) | |
| .slice(0, 140); | |
| } | |
| function buildUnusualVolume(spyHoldings, qqqHoldings, sectors) { | |
| const seen = new Set(); | |
| const stockRows = [...qqqHoldings, ...spyHoldings] | |
| .filter((item) => { | |
| if (!item.ticker || seen.has(item.ticker)) return false; | |
| seen.add(item.ticker); | |
| return Number.isFinite(item.change) && Number.isFinite(item.volumeRatio); | |
| }) | |
| .map((item) => ({ | |
| ticker: item.ticker, | |
| change: item.change, | |
| volumeRatio: item.volumeRatio, | |
| weight: Number.isFinite(item.weight) ? item.weight : 0, | |
| })) | |
| .sort((a, b) => b.volumeRatio - a.volumeRatio) | |
| .slice(0, 40); | |
| if (stockRows.length >= 8) return { mode: "stocks", rows: stockRows }; | |
| return { | |
| mode: "sector-etfs", | |
| rows: sectors | |
| .filter((item) => Number.isFinite(item.change) && Number.isFinite(item.volumeAvgRatio)) | |
| .map((item) => ({ | |
| ticker: item.symbol || item.name, | |
| change: item.change, | |
| volumeRatio: item.volumeAvgRatio, | |
| weight: Number.isFinite(item.volumeM) ? item.volumeM : 0, | |
| })) | |
| .sort((a, b) => b.volumeRatio - a.volumeRatio), | |
| }; | |
| } | |
| function buildRiskRegime(spyTrend, summary) { | |
| const labels = spyTrend?.labels || []; | |
| const values = spyTrend?.series?.[0]?.values || []; | |
| const drawdown = | |
| spyTrend?.drawdown || | |
| values.map((value, index) => { | |
| const priorValues = values.slice(0, index + 1).filter(Number.isFinite); | |
| const runningMax = Math.max(...priorValues); | |
| return Number.isFinite(value) && Number.isFinite(runningMax) && runningMax > 0 ? value / runningMax - 1 : null; | |
| }); | |
| const returns = values | |
| .map((value, index) => (index > 0 && Number.isFinite(value) && Number.isFinite(values[index - 1]) ? value / values[index - 1] - 1 : null)) | |
| .filter(Number.isFinite); | |
| const recentReturns = returns.slice(-20); | |
| const mean = recentReturns.reduce((sum, value) => sum + value, 0) / Math.max(1, recentReturns.length); | |
| const variance = | |
| recentReturns.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, recentReturns.length - 1); | |
| const realizedVol = recentReturns.length > 1 ? Math.sqrt(variance) * Math.sqrt(252) : null; | |
| const latestDrawdown = drawdown.filter(Number.isFinite).at(-1) ?? null; | |
| const maxDrawdown = Math.min(...drawdown.filter(Number.isFinite)); | |
| return { | |
| labels, | |
| series: [ | |
| { name: "SPY drawdown", values: drawdown }, | |
| { name: "VIX", values: labels.map(() => summary?.vix?.price ?? null) }, | |
| ], | |
| metrics: { | |
| currentDrawdown: latestDrawdown, | |
| maxDrawdown: Number.isFinite(maxDrawdown) ? maxDrawdown : null, | |
| realizedVol, | |
| vix: summary?.vix?.price ?? null, | |
| }, | |
| }; | |
| } | |
| function breadthPoint(date, summary) { | |
| const spyTotal = summary?.spy?.total || 0; | |
| const qqqTotal = summary?.qqq?.total || 0; | |
| return { | |
| date, | |
| spyAdvanceRatio: spyTotal ? summary.spy.advance / spyTotal : null, | |
| qqqAdvanceRatio: qqqTotal ? summary.qqq.advance / qqqTotal : null, | |
| spyNetRatio: spyTotal ? (summary.spy.advance - summary.spy.decline) / spyTotal : null, | |
| qqqNetRatio: qqqTotal ? (summary.qqq.advance - summary.qqq.decline) / qqqTotal : null, | |
| }; | |
| } | |
| async function updateBreadthHistory(date, summary) { | |
| const current = breadthPoint(date, summary); | |
| const existing = await readJson(breadthHistoryPath, []); | |
| const history = Array.isArray(existing) ? existing.filter((point) => point?.date !== date) : []; | |
| history.push(current); | |
| history.sort((a, b) => String(a.date).localeCompare(String(b.date))); | |
| const clipped = history.slice(-90); | |
| await writeJson(breadthHistoryPath, clipped); | |
| const rows = clipped.slice(-60); | |
| return { | |
| labels: rows.map((point) => point.date), | |
| series: [ | |
| { name: "SPY Adv%", values: rows.map((point) => point.spyAdvanceRatio) }, | |
| { name: "QQQ Adv%", values: rows.map((point) => point.qqqAdvanceRatio) }, | |
| { name: "SPY Net", values: rows.map((point) => point.spyNetRatio) }, | |
| { name: "QQQ Net", values: rows.map((point) => point.qqqNetRatio) }, | |
| ], | |
| }; | |
| } | |
| function normalizeSeedCharts(seed) { | |
| const mag7Labels = seed.charts.mag7.history.map((point) => String(point.index)); | |
| const mag7Series = seed.charts.mag7.names | |
| .map((name, index) => ({ | |
| name, | |
| values: seed.charts.mag7.history.map((point) => point.values[index]), | |
| })) | |
| .filter((series) => series.name); | |
| const relativeLabels = seed.charts.relative.history.map((point) => String(point.index)); | |
| const relativeSeries = seed.charts.relative.names | |
| .map((name, index) => ({ | |
| name, | |
| values: seed.charts.relative.history.map((point) => point.values[index]), | |
| })) | |
| .filter((series) => series.name); | |
| return { | |
| mag7: { labels: mag7Labels, series: mag7Series }, | |
| relative: { labels: relativeLabels, series: relativeSeries }, | |
| spyTrend: { | |
| labels: seed.charts.spyTrend.map((point) => point.date), | |
| series: [{ name: "SPY", values: seed.charts.spyTrend.map((point) => point.value) }], | |
| drawdown: seed.charts.spyTrend.map((point) => point.drawdown ?? null), | |
| }, | |
| beta: seed.charts.beta, | |
| }; | |
| } | |
| function withLatestPrice(chart, asOf, latestPrice) { | |
| if (!chart || !Number.isFinite(latestPrice)) return chart; | |
| const labels = [...(chart.labels || [])]; | |
| const series = (chart.series || []).map((item) => ({ ...item, values: [...(item.values || [])] })); | |
| if (!labels.length || !series.length) return chart; | |
| const lastIndex = labels.length - 1; | |
| if (labels[lastIndex] === asOf) { | |
| series[0].values[lastIndex] = latestPrice; | |
| } else { | |
| labels.push(asOf); | |
| series[0].values.push(latestPrice); | |
| } | |
| const values = series[0].values; | |
| const drawdown = values.map((value, index) => { | |
| const priorValues = values.slice(0, index + 1).filter(Number.isFinite); | |
| const runningMax = Math.max(...priorValues); | |
| return Number.isFinite(value) && Number.isFinite(runningMax) && runningMax > 0 ? value / runningMax - 1 : null; | |
| }); | |
| return { ...chart, labels, series, drawdown }; | |
| } | |
| function fallbackDashboard(seed, reason = "Using Excel cached data") { | |
| const qqqContributions = seed.qqqContributions; | |
| const charts = normalizeSeedCharts(seed); | |
| const dailyByTicker = new Map([...seed.snapshots.ndx, ...seed.snapshots.spx].map((row) => [row.ticker, row.change])); | |
| const mag7Daily = ["NVDA", "GOOG", "AMZN", "AAPL", "META", "MSFT", "TSLA"].map((ticker) => ({ | |
| ticker, | |
| change: dailyByTicker.get(ticker) ?? null, | |
| })); | |
| return { | |
| mode: "seed", | |
| source: "Excel cache", | |
| status: reason, | |
| asOf: seed.seedAsOf, | |
| generatedAt: new Date().toISOString(), | |
| nextAutoRefresh: "On next New York trading date or manual refresh", | |
| summary: seed.summary, | |
| sectors: seed.sectors, | |
| topMovers: seed.topMovers, | |
| qqqContributions, | |
| qqqContributionTotal: qqqContributions.reduce((sum, row) => sum + (row.contribution || 0), 0), | |
| rangeRows: seed.snapshots.spx.slice(0, 18), | |
| charts: { | |
| ...charts, | |
| mag7Daily, | |
| rangeDistribution: buildRangeDistribution(seed.snapshots.spx), | |
| leadershipMap: buildLeadershipMap(seed.snapshots.spx), | |
| unusualVolume: buildUnusualVolume(seed.snapshots.spx, seed.snapshots.ndx, seed.sectors), | |
| }, | |
| }; | |
| } | |
| function normalizedPerformance(historyBySymbol, symbols) { | |
| const labels = historyBySymbol.get(symbols[0])?.map((point) => point.date) || []; | |
| const series = []; | |
| for (const symbol of symbols) { | |
| const history = historyBySymbol.get(symbol) || []; | |
| const base = history.find((point) => Number.isFinite(point.close))?.close; | |
| if (!base) continue; | |
| series.push({ | |
| name: symbol.replace("^", ""), | |
| values: history.map((point) => ((point.close / base) - 1) * 100), | |
| }); | |
| } | |
| return { labels, series }; | |
| } | |
| async function buildLiveDashboard(seed) { | |
| if (seedOnly) return fallbackDashboard(seed, "Seed-only mode"); | |
| const quoteSymbols = uniq([ | |
| "SPY", | |
| "QQQ", | |
| "^VIX", | |
| ...seed.sectors.map((sector) => sector.symbol), | |
| ...seed.holdings.spy.map((holding) => holding.ticker), | |
| ...seed.holdings.qqq.map((holding) => holding.ticker), | |
| ]); | |
| const quotes = await fetchQuotes(quoteSymbols); | |
| if (!quotes.size) throw new Error("No quote data returned"); | |
| const spxSnapshotByTicker = new Map(seed.snapshots.spx.map((row) => [row.ticker, row])); | |
| const ndxSnapshotByTicker = new Map(seed.snapshots.ndx.map((row) => [row.ticker, row])); | |
| const spyHoldings = seed.holdings.spy.map((holding) => | |
| enrichHolding(holding, quotes, spxSnapshotByTicker.get(holding.ticker)), | |
| ); | |
| const qqqHoldings = seed.holdings.qqq.map((holding) => | |
| enrichHolding(holding, quotes, ndxSnapshotByTicker.get(holding.ticker)), | |
| ); | |
| const spyBreadth = breadth(spyHoldings); | |
| const qqqBreadth = breadth(qqqHoldings); | |
| const sectors = seed.sectors.map((sector) => { | |
| const quote = quoteLookup(quotes, sector.symbol); | |
| const volume = number(quote?.regularMarketVolume); | |
| const avgVolume = number(quote?.averageDailyVolume3Month) || number(quote?.averageDailyVolume10Day); | |
| return { | |
| ...sector, | |
| change: percentFromQuote(quote?.regularMarketChangePercent) ?? sector.change, | |
| volumeM: Number.isFinite(volume) ? volume / 1_000_000 : sector.volumeM, | |
| volumeAvgRatio: Number.isFinite(volume) && Number.isFinite(avgVolume) && avgVolume | |
| ? volume / avgVolume | |
| : sector.volumeAvgRatio, | |
| }; | |
| }); | |
| const mag7Symbols = ["NVDA", "GOOG", "AMZN", "AAPL", "META", "MSFT", "TSLA", "^NDX"]; | |
| const relativeSymbols = ["SPY", "QQQ", "GLD", "BTC-USD"]; | |
| const start2025 = Math.floor(Date.UTC(2025, 0, 1) / 1000); | |
| const thisYear = Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000); | |
| const chartRequests = [ | |
| ...mag7Symbols.map((symbol) => fetchChart(symbol, { range: "1mo" }).then((history) => [symbol, history])), | |
| ...relativeSymbols.map((symbol) => | |
| fetchChart(symbol, { period1: start2025 }).then((history) => [symbol, history]), | |
| ), | |
| fetchChart("SPY", { period1: thisYear }).then((history) => ["SPY_TREND", history]), | |
| ]; | |
| const chartResults = await Promise.allSettled(chartRequests); | |
| const historyBySymbol = new Map(); | |
| for (const result of chartResults) { | |
| if (result.status === "fulfilled" && result.value?.[1]?.length) historyBySymbol.set(result.value[0], result.value[1]); | |
| } | |
| const seedCharts = normalizeSeedCharts(seed); | |
| const mag7Live = normalizedPerformance(historyBySymbol, mag7Symbols); | |
| const relativeLive = normalizedPerformance(historyBySymbol, relativeSymbols); | |
| const spyTrendHistory = historyBySymbol.get("SPY_TREND"); | |
| const spyTrendSeed = withLatestPrice(seedCharts.spyTrend, nyDate(), quotePrice(quotes, "SPY")); | |
| const betaByTicker = new Map(seed.charts.beta.map((row) => [row.ticker, row])); | |
| const betaRows = qqqHoldings | |
| .map((holding) => { | |
| const beta = betaByTicker.get(holding.ticker)?.beta; | |
| return Number.isFinite(beta) && Number.isFinite(holding.change) | |
| ? { ticker: holding.ticker, beta, change: holding.change } | |
| : null; | |
| }) | |
| .filter(Boolean) | |
| .slice(0, 80); | |
| const qqqContributions = topContributions(qqqHoldings); | |
| const mag7Daily = ["NVDA", "GOOG", "AMZN", "AAPL", "META", "MSFT", "TSLA"].map((ticker) => { | |
| const holding = qqqHoldings.find((item) => item.ticker === ticker) || spyHoldings.find((item) => item.ticker === ticker); | |
| return { ticker, change: quoteChange(quotes, ticker) ?? holding?.change ?? null }; | |
| }); | |
| return { | |
| mode: "live", | |
| source: "Yahoo/Stooq live quotes with Excel holdings", | |
| status: "Live market data", | |
| asOf: nyDate(), | |
| generatedAt: new Date().toISOString(), | |
| nextAutoRefresh: "Daily, keyed to the New York market date", | |
| summary: { | |
| spy: { ...spyBreadth, change: quoteChange(quotes, "SPY") ?? seed.summary.spy.change }, | |
| qqq: { ...qqqBreadth, change: quoteChange(quotes, "QQQ") ?? seed.summary.qqq.change }, | |
| vix: { | |
| price: quotePrice(quotes, "^VIX") ?? seed.summary.vix.price, | |
| change: quoteChange(quotes, "^VIX") ?? seed.summary.vix.change, | |
| }, | |
| }, | |
| sectors, | |
| topMovers: { | |
| spyLosers: topMovers(spyHoldings).losers, | |
| spyGainers: topMovers(spyHoldings).gainers, | |
| qqqLosers: topMovers(qqqHoldings).losers, | |
| qqqGainers: topMovers(qqqHoldings).gainers, | |
| }, | |
| qqqContributions, | |
| qqqContributionTotal: qqqContributions.reduce((sum, row) => sum + row.contribution, 0), | |
| rangeRows: topRangeRows(spyHoldings), | |
| charts: { | |
| mag7: mag7Live.series.length >= 2 ? mag7Live : seedCharts.mag7, | |
| relative: relativeLive.series.length >= 2 ? relativeLive : seedCharts.relative, | |
| spyTrend: spyTrendHistory?.length | |
| ? { | |
| labels: spyTrendHistory.map((point) => point.date), | |
| series: [{ name: "SPY", values: spyTrendHistory.map((point) => point.close) }], | |
| } | |
| : spyTrendSeed, | |
| beta: betaRows.length ? betaRows : seedCharts.beta, | |
| mag7Daily, | |
| rangeDistribution: buildRangeDistribution(spyHoldings), | |
| leadershipMap: buildLeadershipMap(spyHoldings), | |
| unusualVolume: buildUnusualVolume(spyHoldings, qqqHoldings, sectors), | |
| }, | |
| }; | |
| } | |
| async function getDashboard(forceRefresh = false) { | |
| const today = nyDate(); | |
| const sourceMode = seedOnly ? "seed-only" : "normal"; | |
| if (!forceRefresh && memoryCache?.cacheDate === today) return memoryCache.data; | |
| const diskCache = await readJson(cachePath); | |
| if (!forceRefresh && diskCache?.cacheDate === today && diskCache?.sourceMode === sourceMode && diskCache?.data) { | |
| memoryCache = diskCache; | |
| return diskCache.data; | |
| } | |
| const seed = await readJson(seedPath); | |
| if (!seed) throw new Error("Missing data/seed-data.json. Run npm run seed first."); | |
| let data; | |
| try { | |
| data = await buildLiveDashboard(seed); | |
| } catch (error) { | |
| data = fallbackDashboard(seed, `Live refresh failed: ${error.message}`); | |
| } | |
| data.charts = data.charts || {}; | |
| data.charts.breadthTrend = await updateBreadthHistory(data.asOf || today, data.summary); | |
| data.charts.riskRegime = buildRiskRegime(data.charts.spyTrend, data.summary); | |
| memoryCache = { cacheDate: today, sourceMode, data }; | |
| await writeJson(cachePath, memoryCache); | |
| return data; | |
| } | |
| function sendJson(res, status, data) { | |
| const body = JSON.stringify(data); | |
| res.writeHead(status, { | |
| "content-type": "application/json; charset=utf-8", | |
| "cache-control": "no-store", | |
| }); | |
| res.end(body); | |
| } | |
| function contentType(filePath) { | |
| const ext = path.extname(filePath); | |
| return ( | |
| { | |
| ".html": "text/html; charset=utf-8", | |
| ".css": "text/css; charset=utf-8", | |
| ".js": "text/javascript; charset=utf-8", | |
| ".json": "application/json; charset=utf-8", | |
| ".svg": "image/svg+xml", | |
| }[ext] || "application/octet-stream" | |
| ); | |
| } | |
| async function serveStatic(req, res, pathname) { | |
| const cleanPath = pathname === "/" ? "/index.html" : decodeURIComponent(pathname); | |
| const filePath = path.normalize(path.join(publicDir, cleanPath)); | |
| if (!filePath.startsWith(publicDir)) { | |
| res.writeHead(403); | |
| res.end("Forbidden"); | |
| return; | |
| } | |
| try { | |
| const body = await fs.readFile(filePath); | |
| res.writeHead(200, { | |
| "content-type": contentType(filePath), | |
| "cache-control": "no-cache", | |
| }); | |
| res.end(body); | |
| } catch { | |
| res.writeHead(404); | |
| res.end("Not found"); | |
| } | |
| } | |
| const server = http.createServer(async (req, res) => { | |
| const url = new URL(req.url, `http://${req.headers.host}`); | |
| if (url.pathname === "/api/dashboard") { | |
| try { | |
| const data = await getDashboard(url.searchParams.get("refresh") === "1"); | |
| sendJson(res, 200, data); | |
| } catch (error) { | |
| sendJson(res, 500, { error: error.message }); | |
| } | |
| return; | |
| } | |
| await serveStatic(req, res, url.pathname); | |
| }); | |
| server.listen(port, host, () => { | |
| const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host; | |
| console.log(`Gen Dashboard page: http://${displayHost}:${port}`); | |
| }); | |
| setInterval(() => { | |
| getDashboard(false).catch((error) => { | |
| console.error("Scheduled refresh failed:", error.message); | |
| }); | |
| }, 60 * 60 * 1000); | |