mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(market): route sectors/commodities to correct RPC endpoints (#2198)
* fix(fear-greed): add undici to scripts/package.json (ERR_MODULE_NOT_FOUND on Railway) * fix(market): route sectors/commodities to correct RPC endpoints fetchMultipleStocks called listMarketQuotes which reads market:stocks-bootstrap:v1. Sector ETFs (XLK, XLF...) and commodity futures (GC=F, CL=F...) are NOT in that key, so the live-fetch fallback always returned empty after the one-shot bootstrap hydration was consumed, causing panels to show "data temporarily unavailable" on every reload. Fix: add fetchSectors() -> getSectorSummary (reads market:sectors:v1) and fetchCommodityQuotes() -> listCommodityQuotes (reads market:commodities-bootstrap:v1), each with their own circuit breaker and persistent cache. Remove useCommodityBreaker option from fetchMultipleStocks which no longer serves commodities. * feat(heatmap): show friendly sector names instead of ETF tickers The relay seeds name:ticker into Redis (market:sectors:v1), so the heatmap showed XLK/XLF/etc which is non-intuitive for most users. Fix: build a sectorNameMap from shared/sectors.json (keyed by symbol) and apply it in both the hydrated and live fetch paths. Also update sectors.json names from ultra-short aliases (Tech, Finance) to clearer labels (Technology, Financials, Health Care, etc). Closes #2194 * sync scripts/shared/sectors.json * feat(heatmap): show ticker + sector name side by side Each tile now shows: XLK <- dim ticker (for professionals) Technology <- full sector name (for laymen) +1.23% Sector names updated: Tech→Technology, Finance→Financials, Health→Health Care, Real Est→Real Estate, Comms→Comm. Svcs, etc. Refs #2194
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"sectors": [
|
"sectors": [
|
||||||
{ "symbol": "XLK", "name": "Tech" },
|
{ "symbol": "XLK", "name": "Technology" },
|
||||||
{ "symbol": "XLF", "name": "Finance" },
|
{ "symbol": "XLF", "name": "Financials" },
|
||||||
{ "symbol": "XLE", "name": "Energy" },
|
{ "symbol": "XLE", "name": "Energy" },
|
||||||
{ "symbol": "XLV", "name": "Health" },
|
{ "symbol": "XLV", "name": "Health Care" },
|
||||||
{ "symbol": "XLY", "name": "Consumer" },
|
{ "symbol": "XLY", "name": "Consumer Disc." },
|
||||||
{ "symbol": "XLI", "name": "Industrial" },
|
{ "symbol": "XLI", "name": "Industrials" },
|
||||||
{ "symbol": "XLP", "name": "Staples" },
|
{ "symbol": "XLP", "name": "Con. Staples" },
|
||||||
{ "symbol": "XLU", "name": "Utilities" },
|
{ "symbol": "XLU", "name": "Utilities" },
|
||||||
{ "symbol": "XLB", "name": "Materials" },
|
{ "symbol": "XLB", "name": "Materials" },
|
||||||
{ "symbol": "XLRE", "name": "Real Est" },
|
{ "symbol": "XLRE", "name": "Real Estate" },
|
||||||
{ "symbol": "XLC", "name": "Comms" },
|
{ "symbol": "XLC", "name": "Comm. Svcs" },
|
||||||
{ "symbol": "SMH", "name": "Semis" }
|
{ "symbol": "SMH", "name": "Semiconductors" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"sectors": [
|
"sectors": [
|
||||||
{ "symbol": "XLK", "name": "Tech" },
|
{ "symbol": "XLK", "name": "Technology" },
|
||||||
{ "symbol": "XLF", "name": "Finance" },
|
{ "symbol": "XLF", "name": "Financials" },
|
||||||
{ "symbol": "XLE", "name": "Energy" },
|
{ "symbol": "XLE", "name": "Energy" },
|
||||||
{ "symbol": "XLV", "name": "Health" },
|
{ "symbol": "XLV", "name": "Health Care" },
|
||||||
{ "symbol": "XLY", "name": "Consumer" },
|
{ "symbol": "XLY", "name": "Consumer Disc." },
|
||||||
{ "symbol": "XLI", "name": "Industrial" },
|
{ "symbol": "XLI", "name": "Industrials" },
|
||||||
{ "symbol": "XLP", "name": "Staples" },
|
{ "symbol": "XLP", "name": "Con. Staples" },
|
||||||
{ "symbol": "XLU", "name": "Utilities" },
|
{ "symbol": "XLU", "name": "Utilities" },
|
||||||
{ "symbol": "XLB", "name": "Materials" },
|
{ "symbol": "XLB", "name": "Materials" },
|
||||||
{ "symbol": "XLRE", "name": "Real Est" },
|
{ "symbol": "XLRE", "name": "Real Estate" },
|
||||||
{ "symbol": "XLC", "name": "Comms" },
|
{ "symbol": "XLC", "name": "Comm. Svcs" },
|
||||||
{ "symbol": "SMH", "name": "Semis" }
|
{ "symbol": "SMH", "name": "Semiconductors" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
fetchCategoryFeeds,
|
fetchCategoryFeeds,
|
||||||
getFeedFailures,
|
getFeedFailures,
|
||||||
fetchMultipleStocks,
|
fetchMultipleStocks,
|
||||||
|
fetchCommodityQuotes,
|
||||||
|
fetchSectors,
|
||||||
fetchCrypto,
|
fetchCrypto,
|
||||||
fetchCryptoSectors,
|
fetchCryptoSectors,
|
||||||
fetchDefiTokens,
|
fetchDefiTokens,
|
||||||
@@ -1254,25 +1256,21 @@ export class DataLoaderManager implements AppModule {
|
|||||||
// Sector heatmap: always attempt loading regardless of market rate-limit status
|
// Sector heatmap: always attempt loading regardless of market rate-limit status
|
||||||
const hydratedSectors = getHydratedData('sectors') as GetSectorSummaryResponse | undefined;
|
const hydratedSectors = getHydratedData('sectors') as GetSectorSummaryResponse | undefined;
|
||||||
const heatmapPanel = this.ctx.panels['heatmap'] as HeatmapPanel | undefined;
|
const heatmapPanel = this.ctx.panels['heatmap'] as HeatmapPanel | undefined;
|
||||||
|
const sectorNameMap = new Map(SECTORS.map((s) => [s.symbol, s.name]));
|
||||||
|
const toHeatmapItem = (s: { symbol: string; name: string; change: number }) => ({
|
||||||
|
symbol: s.symbol,
|
||||||
|
name: sectorNameMap.get(s.symbol) ?? s.name,
|
||||||
|
change: s.change,
|
||||||
|
});
|
||||||
if (hydratedSectors?.sectors?.length) {
|
if (hydratedSectors?.sectors?.length) {
|
||||||
const mapped = hydratedSectors.sectors.map((s) => ({ name: s.name, change: s.change }));
|
heatmapPanel?.renderHeatmap(hydratedSectors.sectors.map(toHeatmapItem));
|
||||||
heatmapPanel?.renderHeatmap(mapped);
|
|
||||||
} else if (!stocksResult.skipped) {
|
|
||||||
const sectorsResult = await fetchMultipleStocks(
|
|
||||||
SECTORS.map((s) => ({ ...s, display: s.name })),
|
|
||||||
{
|
|
||||||
onBatch: (partialSectors) => {
|
|
||||||
heatmapPanel?.renderHeatmap(
|
|
||||||
partialSectors.map((s) => ({ name: s.name, change: s.change }))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
heatmapPanel?.renderHeatmap(
|
|
||||||
sectorsResult.data.map((s) => ({ name: s.name, change: s.change }))
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
this.ctx.panels['heatmap']?.showConfigError(finnhubConfigMsg);
|
const sectorsResp = await fetchSectors();
|
||||||
|
if (sectorsResp.sectors.length > 0) {
|
||||||
|
heatmapPanel?.renderHeatmap(sectorsResp.sectors.map(toHeatmapItem));
|
||||||
|
} else if (stocksResult.skipped) {
|
||||||
|
this.ctx.panels['heatmap']?.showConfigError(finnhubConfigMsg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commoditiesPanel = this.ctx.panels['commodities'] as CommoditiesPanel | undefined;
|
const commoditiesPanel = this.ctx.panels['commodities'] as CommoditiesPanel | undefined;
|
||||||
@@ -1312,14 +1310,13 @@ export class DataLoaderManager implements AppModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 1 && (!metalsLoaded || !energyLoaded); attempt++) {
|
for (let attempt = 0; attempt < 1 && (!metalsLoaded || !energyLoaded); attempt++) {
|
||||||
const commoditiesResult = await fetchMultipleStocks(COMMODITIES, {
|
const commoditiesResult = await fetchCommodityQuotes(COMMODITIES, {
|
||||||
onBatch: (partial) => {
|
onBatch: (partial) => {
|
||||||
const commodityMapped = filterCommodityTape(partial).map(mapCommodity);
|
const commodityMapped = filterCommodityTape(partial).map(mapCommodity);
|
||||||
const energyMapped = filterEnergyTape(partial);
|
const energyMapped = filterEnergyTape(partial);
|
||||||
if (commoditiesPanel) commoditiesPanel.renderCommodities(commodityMapped);
|
if (commoditiesPanel) commoditiesPanel.renderCommodities(commodityMapped);
|
||||||
energyPanel?.updateTape(energyMapped);
|
energyPanel?.updateTape(energyMapped);
|
||||||
},
|
},
|
||||||
useCommodityBreaker: true,
|
|
||||||
});
|
});
|
||||||
const commodityMapped = filterCommodityTape(commoditiesResult.data).map(mapCommodity);
|
const commodityMapped = filterCommodityTape(commoditiesResult.data).map(mapCommodity);
|
||||||
const energyMapped = filterEnergyTape(commoditiesResult.data);
|
const energyMapped = filterEnergyTape(commoditiesResult.data);
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export class HeatmapPanel extends Panel {
|
|||||||
super({ id: 'heatmap', title: t('panels.heatmap'), infoTooltip: t('components.heatmap.infoTooltip') });
|
super({ id: 'heatmap', title: t('panels.heatmap'), infoTooltip: t('components.heatmap.infoTooltip') });
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderHeatmap(data: Array<{ name: string; change: number | null }>): void {
|
public renderHeatmap(data: Array<{ symbol?: string; name: string; change: number | null }>): void {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
this.showRetrying(t('common.failedSectorData'));
|
this.showRetrying(t('common.failedSectorData'));
|
||||||
return;
|
return;
|
||||||
@@ -147,17 +147,19 @@ export class HeatmapPanel extends Panel {
|
|||||||
const html =
|
const html =
|
||||||
'<div class="heatmap">' +
|
'<div class="heatmap">' +
|
||||||
data
|
data
|
||||||
.map(
|
.map((sector) => {
|
||||||
(sector) => {
|
const change = sector.change ?? 0;
|
||||||
const change = sector.change ?? 0;
|
const tickerHtml = sector.symbol
|
||||||
return `
|
? `<div class="sector-ticker">${escapeHtml(sector.symbol)}</div>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
<div class="heatmap-cell ${getHeatmapClass(change)}">
|
<div class="heatmap-cell ${getHeatmapClass(change)}">
|
||||||
|
${tickerHtml}
|
||||||
<div class="sector-name">${escapeHtml(sector.name)}</div>
|
<div class="sector-name">${escapeHtml(sector.name)}</div>
|
||||||
<div class="sector-change ${getChangeClass(change)}">${formatChange(change)}</div>
|
<div class="sector-change ${getChangeClass(change)}">${formatChange(change)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
})
|
||||||
)
|
|
||||||
.join('') +
|
.join('') +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { getRpcBaseUrl } from '@/services/rpc-client';
|
|||||||
import {
|
import {
|
||||||
MarketServiceClient,
|
MarketServiceClient,
|
||||||
type ListMarketQuotesResponse,
|
type ListMarketQuotesResponse,
|
||||||
|
type ListCommodityQuotesResponse,
|
||||||
|
type GetSectorSummaryResponse,
|
||||||
type ListCryptoQuotesResponse,
|
type ListCryptoQuotesResponse,
|
||||||
type ListCryptoSectorsResponse,
|
type ListCryptoSectorsResponse,
|
||||||
type CryptoSector,
|
type CryptoSector,
|
||||||
@@ -27,7 +29,8 @@ import { getHydratedData } from '@/services/bootstrap';
|
|||||||
const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
|
const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
|
||||||
const MARKET_QUOTES_CACHE_TTL_MS = 5 * 60 * 1000;
|
const MARKET_QUOTES_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
const stockBreaker = createCircuitBreaker<ListMarketQuotesResponse>({ name: 'Market Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
|
const stockBreaker = createCircuitBreaker<ListMarketQuotesResponse>({ name: 'Market Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
|
||||||
const commodityBreaker = createCircuitBreaker<ListMarketQuotesResponse>({ name: 'Commodity Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
|
const commodityBreaker = createCircuitBreaker<ListCommodityQuotesResponse>({ name: 'Commodity Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
|
||||||
|
const sectorBreaker = createCircuitBreaker<GetSectorSummaryResponse>({ name: 'Sector Summary', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
|
||||||
const cryptoBreaker = createCircuitBreaker<ListCryptoQuotesResponse>({ name: 'Crypto Quotes', persistCache: true });
|
const cryptoBreaker = createCircuitBreaker<ListCryptoQuotesResponse>({ name: 'Crypto Quotes', persistCache: true });
|
||||||
const cryptoSectorsBreaker = createCircuitBreaker<ListCryptoSectorsResponse>({ name: 'Crypto Sectors', persistCache: true });
|
const cryptoSectorsBreaker = createCircuitBreaker<ListCryptoSectorsResponse>({ name: 'Crypto Sectors', persistCache: true });
|
||||||
const defiBreaker = createCircuitBreaker<ListDefiTokensResponse>({ name: 'DeFi Tokens', persistCache: true });
|
const defiBreaker = createCircuitBreaker<ListDefiTokensResponse>({ name: 'DeFi Tokens', persistCache: true });
|
||||||
@@ -35,6 +38,8 @@ const aiBreaker = createCircuitBreaker<ListAiTokensResponse>({ name: 'AI Tokens'
|
|||||||
const otherBreaker = createCircuitBreaker<ListOtherTokensResponse>({ name: 'Other Tokens', persistCache: true });
|
const otherBreaker = createCircuitBreaker<ListOtherTokensResponse>({ name: 'Other Tokens', persistCache: true });
|
||||||
|
|
||||||
const emptyStockFallback: ListMarketQuotesResponse = { quotes: [], finnhubSkipped: false, skipReason: '', rateLimited: false };
|
const emptyStockFallback: ListMarketQuotesResponse = { quotes: [], finnhubSkipped: false, skipReason: '', rateLimited: false };
|
||||||
|
const emptyCommodityFallback: ListCommodityQuotesResponse = { quotes: [] };
|
||||||
|
const emptySectorFallback: GetSectorSummaryResponse = { sectors: [] };
|
||||||
const emptyCryptoFallback: ListCryptoQuotesResponse = { quotes: [] };
|
const emptyCryptoFallback: ListCryptoQuotesResponse = { quotes: [] };
|
||||||
const emptyCryptoSectorsFallback: ListCryptoSectorsResponse = { sectors: [] };
|
const emptyCryptoSectorsFallback: ListCryptoSectorsResponse = { sectors: [] };
|
||||||
const emptyDefiTokensFallback: ListDefiTokensResponse = { tokens: [] };
|
const emptyDefiTokensFallback: ListDefiTokensResponse = { tokens: [] };
|
||||||
@@ -87,7 +92,7 @@ function symbolSetKey(symbols: string[]): string {
|
|||||||
|
|
||||||
export async function fetchMultipleStocks(
|
export async function fetchMultipleStocks(
|
||||||
symbols: Array<{ symbol: string; name: string; display: string }>,
|
symbols: Array<{ symbol: string; name: string; display: string }>,
|
||||||
options: { onBatch?: (results: MarketData[]) => void; useCommodityBreaker?: boolean } = {},
|
options: { onBatch?: (results: MarketData[]) => void } = {},
|
||||||
): Promise<MarketFetchResult> {
|
): Promise<MarketFetchResult> {
|
||||||
// Preserve exact requested symbols for cache keys and request payloads so
|
// Preserve exact requested symbols for cache keys and request payloads so
|
||||||
// case-distinct instruments do not collapse into one cache entry.
|
// case-distinct instruments do not collapse into one cache entry.
|
||||||
@@ -110,8 +115,7 @@ export async function fetchMultipleStocks(
|
|||||||
const allSymbolStrings = [...symbolMetaMap.keys()];
|
const allSymbolStrings = [...symbolMetaMap.keys()];
|
||||||
const setKey = symbolSetKey(allSymbolStrings);
|
const setKey = symbolSetKey(allSymbolStrings);
|
||||||
|
|
||||||
const breaker = options.useCommodityBreaker ? commodityBreaker : stockBreaker;
|
const resp = await stockBreaker.execute(async () => {
|
||||||
const resp = await breaker.execute(async () => {
|
|
||||||
return client.listMarketQuotes({ symbols: allSymbolStrings });
|
return client.listMarketQuotes({ symbols: allSymbolStrings });
|
||||||
}, emptyStockFallback, {
|
}, emptyStockFallback, {
|
||||||
cacheKey: setKey,
|
cacheKey: setKey,
|
||||||
@@ -151,6 +155,53 @@ export async function fetchStockQuote(
|
|||||||
return result.data[0] || { symbol, name, display, price: null, change: null };
|
return result.data[0] || { symbol, name, display, price: null, change: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Commodities -- uses listCommodityQuotes (reads market:commodities-bootstrap:v1)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
export async function fetchCommodityQuotes(
|
||||||
|
commodities: Array<{ symbol: string; name: string; display: string }>,
|
||||||
|
options: { onBatch?: (results: MarketData[]) => void } = {},
|
||||||
|
): Promise<MarketFetchResult> {
|
||||||
|
const symbols = commodities.map((c) => c.symbol);
|
||||||
|
const meta = new Map(commodities.map((c) => [c.symbol, c]));
|
||||||
|
const cacheKey = [...symbols].sort().join(',');
|
||||||
|
|
||||||
|
const resp = await commodityBreaker.execute(async () => {
|
||||||
|
return client.listCommodityQuotes({ symbols });
|
||||||
|
}, emptyCommodityFallback, {
|
||||||
|
cacheKey,
|
||||||
|
shouldCache: (r: ListCommodityQuotesResponse) => r.quotes.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: MarketData[] = resp.quotes.map((q) => {
|
||||||
|
const m = meta.get(q.symbol);
|
||||||
|
return {
|
||||||
|
symbol: q.symbol,
|
||||||
|
name: m?.name ?? q.name,
|
||||||
|
display: m?.display ?? q.display ?? q.symbol,
|
||||||
|
price: q.price,
|
||||||
|
change: q.change,
|
||||||
|
sparkline: q.sparkline?.length > 0 ? q.sparkline : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length > 0) options.onBatch?.(results);
|
||||||
|
return { data: results };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Sectors -- uses getSectorSummary (reads market:sectors:v1)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
export async function fetchSectors(): Promise<GetSectorSummaryResponse> {
|
||||||
|
return sectorBreaker.execute(async () => {
|
||||||
|
return client.getSectorSummary({ period: '' });
|
||||||
|
}, emptySectorFallback, {
|
||||||
|
shouldCache: (r: GetSectorSummaryResponse) => r.sectors.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Crypto -- replaces fetchCrypto
|
// Crypto -- replaces fetchCrypto
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -5834,9 +5834,18 @@ body.playback-mode .status-dot {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sector-name {
|
.sector-ticker {
|
||||||
font-size: 9px;
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-name {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user