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:
Elie Habib
2026-03-24 17:26:29 +04:00
committed by GitHub
parent f8b793dcc8
commit 663a58bf80
6 changed files with 109 additions and 50 deletions

View File

@@ -1,16 +1,16 @@
{
"sectors": [
{ "symbol": "XLK", "name": "Tech" },
{ "symbol": "XLF", "name": "Finance" },
{ "symbol": "XLK", "name": "Technology" },
{ "symbol": "XLF", "name": "Financials" },
{ "symbol": "XLE", "name": "Energy" },
{ "symbol": "XLV", "name": "Health" },
{ "symbol": "XLY", "name": "Consumer" },
{ "symbol": "XLI", "name": "Industrial" },
{ "symbol": "XLP", "name": "Staples" },
{ "symbol": "XLV", "name": "Health Care" },
{ "symbol": "XLY", "name": "Consumer Disc." },
{ "symbol": "XLI", "name": "Industrials" },
{ "symbol": "XLP", "name": "Con. Staples" },
{ "symbol": "XLU", "name": "Utilities" },
{ "symbol": "XLB", "name": "Materials" },
{ "symbol": "XLRE", "name": "Real Est" },
{ "symbol": "XLC", "name": "Comms" },
{ "symbol": "SMH", "name": "Semis" }
{ "symbol": "XLRE", "name": "Real Estate" },
{ "symbol": "XLC", "name": "Comm. Svcs" },
{ "symbol": "SMH", "name": "Semiconductors" }
]
}

View File

@@ -1,16 +1,16 @@
{
"sectors": [
{ "symbol": "XLK", "name": "Tech" },
{ "symbol": "XLF", "name": "Finance" },
{ "symbol": "XLK", "name": "Technology" },
{ "symbol": "XLF", "name": "Financials" },
{ "symbol": "XLE", "name": "Energy" },
{ "symbol": "XLV", "name": "Health" },
{ "symbol": "XLY", "name": "Consumer" },
{ "symbol": "XLI", "name": "Industrial" },
{ "symbol": "XLP", "name": "Staples" },
{ "symbol": "XLV", "name": "Health Care" },
{ "symbol": "XLY", "name": "Consumer Disc." },
{ "symbol": "XLI", "name": "Industrials" },
{ "symbol": "XLP", "name": "Con. Staples" },
{ "symbol": "XLU", "name": "Utilities" },
{ "symbol": "XLB", "name": "Materials" },
{ "symbol": "XLRE", "name": "Real Est" },
{ "symbol": "XLC", "name": "Comms" },
{ "symbol": "SMH", "name": "Semis" }
{ "symbol": "XLRE", "name": "Real Estate" },
{ "symbol": "XLC", "name": "Comm. Svcs" },
{ "symbol": "SMH", "name": "Semiconductors" }
]
}

View File

@@ -19,6 +19,8 @@ import {
fetchCategoryFeeds,
getFeedFailures,
fetchMultipleStocks,
fetchCommodityQuotes,
fetchSectors,
fetchCrypto,
fetchCryptoSectors,
fetchDefiTokens,
@@ -1254,26 +1256,22 @@ export class DataLoaderManager implements AppModule {
// Sector heatmap: always attempt loading regardless of market rate-limit status
const hydratedSectors = getHydratedData('sectors') as GetSectorSummaryResponse | 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) {
const mapped = hydratedSectors.sectors.map((s) => ({ name: s.name, change: s.change }));
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 }))
);
heatmapPanel?.renderHeatmap(hydratedSectors.sectors.map(toHeatmapItem));
} else {
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 energyPanel = this.ctx.panels['energy-complex'] as EnergyComplexPanel | undefined;
@@ -1312,14 +1310,13 @@ export class DataLoaderManager implements AppModule {
}
for (let attempt = 0; attempt < 1 && (!metalsLoaded || !energyLoaded); attempt++) {
const commoditiesResult = await fetchMultipleStocks(COMMODITIES, {
const commoditiesResult = await fetchCommodityQuotes(COMMODITIES, {
onBatch: (partial) => {
const commodityMapped = filterCommodityTape(partial).map(mapCommodity);
const energyMapped = filterEnergyTape(partial);
if (commoditiesPanel) commoditiesPanel.renderCommodities(commodityMapped);
energyPanel?.updateTape(energyMapped);
},
useCommodityBreaker: true,
});
const commodityMapped = filterCommodityTape(commoditiesResult.data).map(mapCommodity);
const energyMapped = filterEnergyTape(commoditiesResult.data);

View File

@@ -138,7 +138,7 @@ export class HeatmapPanel extends Panel {
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) {
this.showRetrying(t('common.failedSectorData'));
return;
@@ -147,17 +147,19 @@ export class HeatmapPanel extends Panel {
const html =
'<div class="heatmap">' +
data
.map(
(sector) => {
.map((sector) => {
const change = sector.change ?? 0;
const tickerHtml = sector.symbol
? `<div class="sector-ticker">${escapeHtml(sector.symbol)}</div>`
: '';
return `
<div class="heatmap-cell ${getHeatmapClass(change)}">
${tickerHtml}
<div class="sector-name">${escapeHtml(sector.name)}</div>
<div class="sector-change ${getChangeClass(change)}">${formatChange(change)}</div>
</div>
`;
}
)
})
.join('') +
'</div>';

View File

@@ -9,6 +9,8 @@ import { getRpcBaseUrl } from '@/services/rpc-client';
import {
MarketServiceClient,
type ListMarketQuotesResponse,
type ListCommodityQuotesResponse,
type GetSectorSummaryResponse,
type ListCryptoQuotesResponse,
type ListCryptoSectorsResponse,
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 MARKET_QUOTES_CACHE_TTL_MS = 5 * 60 * 1000;
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 cryptoSectorsBreaker = createCircuitBreaker<ListCryptoSectorsResponse>({ name: 'Crypto Sectors', 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 emptyStockFallback: ListMarketQuotesResponse = { quotes: [], finnhubSkipped: false, skipReason: '', rateLimited: false };
const emptyCommodityFallback: ListCommodityQuotesResponse = { quotes: [] };
const emptySectorFallback: GetSectorSummaryResponse = { sectors: [] };
const emptyCryptoFallback: ListCryptoQuotesResponse = { quotes: [] };
const emptyCryptoSectorsFallback: ListCryptoSectorsResponse = { sectors: [] };
const emptyDefiTokensFallback: ListDefiTokensResponse = { tokens: [] };
@@ -87,7 +92,7 @@ function symbolSetKey(symbols: string[]): string {
export async function fetchMultipleStocks(
symbols: Array<{ symbol: string; name: string; display: string }>,
options: { onBatch?: (results: MarketData[]) => void; useCommodityBreaker?: boolean } = {},
options: { onBatch?: (results: MarketData[]) => void } = {},
): Promise<MarketFetchResult> {
// Preserve exact requested symbols for cache keys and request payloads so
// case-distinct instruments do not collapse into one cache entry.
@@ -110,8 +115,7 @@ export async function fetchMultipleStocks(
const allSymbolStrings = [...symbolMetaMap.keys()];
const setKey = symbolSetKey(allSymbolStrings);
const breaker = options.useCommodityBreaker ? commodityBreaker : stockBreaker;
const resp = await breaker.execute(async () => {
const resp = await stockBreaker.execute(async () => {
return client.listMarketQuotes({ symbols: allSymbolStrings });
}, emptyStockFallback, {
cacheKey: setKey,
@@ -151,6 +155,53 @@ export async function fetchStockQuote(
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
// ========================================================================

View File

@@ -5834,9 +5834,18 @@ body.playback-mode .status-dot {
background: var(--surface);
}
.sector-name {
font-size: 9px;
.sector-ticker {
font-size: 8px;
font-weight: 600;
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;
}