gen-dashboard / server.mjs
kyLELEng's picture
Deploy Gen Dashboard private Space
05de8c5 verified
Raw
History Blame Contribute Delete
26.9 kB
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);