diff --git a/scripts/shared/sectors.json b/scripts/shared/sectors.json index 5a8510574..c98cd0b83 100644 --- a/scripts/shared/sectors.json +++ b/scripts/shared/sectors.json @@ -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" } ] } diff --git a/shared/sectors.json b/shared/sectors.json index 5a8510574..c98cd0b83 100644 --- a/shared/sectors.json +++ b/shared/sectors.json @@ -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" } ] } diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 3852b8fa9..17b0c286d 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -19,6 +19,8 @@ import { fetchCategoryFeeds, getFeedFailures, fetchMultipleStocks, + fetchCommodityQuotes, + fetchSectors, fetchCrypto, fetchCryptoSectors, fetchDefiTokens, @@ -1254,25 +1256,21 @@ 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 { - 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; @@ -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); diff --git a/src/components/MarketPanel.ts b/src/components/MarketPanel.ts index ea59eccfc..19405078d 100644 --- a/src/components/MarketPanel.ts +++ b/src/components/MarketPanel.ts @@ -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 = '
' + data - .map( - (sector) => { - const change = sector.change ?? 0; - return ` + .map((sector) => { + const change = sector.change ?? 0; + const tickerHtml = sector.symbol + ? `
${escapeHtml(sector.symbol)}
` + : ''; + return `
+ ${tickerHtml}
${escapeHtml(sector.name)}
${formatChange(change)}
`; - } - ) + }) .join('') + '
'; diff --git a/src/services/market/index.ts b/src/services/market/index.ts index 058eac521..179993619 100644 --- a/src/services/market/index.ts +++ b/src/services/market/index.ts @@ -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) => globalThis.fetch(...args) }); const MARKET_QUOTES_CACHE_TTL_MS = 5 * 60 * 1000; const stockBreaker = createCircuitBreaker({ name: 'Market Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true }); -const commodityBreaker = createCircuitBreaker({ name: 'Commodity Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true }); +const commodityBreaker = createCircuitBreaker({ name: 'Commodity Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true }); +const sectorBreaker = createCircuitBreaker({ name: 'Sector Summary', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true }); const cryptoBreaker = createCircuitBreaker({ name: 'Crypto Quotes', persistCache: true }); const cryptoSectorsBreaker = createCircuitBreaker({ name: 'Crypto Sectors', persistCache: true }); const defiBreaker = createCircuitBreaker({ name: 'DeFi Tokens', persistCache: true }); @@ -35,6 +38,8 @@ const aiBreaker = createCircuitBreaker({ name: 'AI Tokens' const otherBreaker = createCircuitBreaker({ 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 { // 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 { + 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 { + return sectorBreaker.execute(async () => { + return client.getSectorSummary({ period: '' }); + }, emptySectorFallback, { + shouldCache: (r: GetSectorSummaryResponse) => r.sectors.length > 0, + }); +} + // ======================================================================== // Crypto -- replaces fetchCrypto // ======================================================================== diff --git a/src/styles/main.css b/src/styles/main.css index cce21076d..2ed7edcac 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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; }