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": [
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>';
|
||||
|
||||
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user