import { EmptyState, SectionHeading, usePaneNoticeFooter } from "../../../components"; import { CompositeChart, pricePointsToResolvedSeries } from "../../../components/chart/composite"; import { CompanyLogo } from "../../../components/company-logo"; import { PriceReturnStrip } from "../../../components/price-performance"; import { t } from "../../../i18n"; import { useFxRatesMap } from "../../../market-data/hooks"; import { formatMarketPriceWithCurrency, formatSignedMarketPrice, liveQuoteFormatOptions } from "../../../market-data/market/format"; import { exchangeShortName, marketStateColor, marketStateLabel } from "../../../market-data/market/status"; import { appendQuoteToPriceReturnHistory, buildPriceReturnFields } from "../../../market-data/performance"; import { useViewport } from "../../../react/input"; import { useAppSelector } from "../../../state/app/context"; import { colors, priceColor } from "../../../theme/colors"; import { appendLiveQuotePoint, hasUnknownBondHistoryBasis } from "../../../time-series/chart-data"; import type { TickerFinancials } from "../../../types/financials"; import type { TickerRecord } from "../../../types/ticker"; import { Box, ScrollBox, Text, TextAttributes, useUiCapabilities } from "../../../ui"; import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map"; import { resolveExchangeTimeZone } from "../../../utils/exchanges"; import { convertCurrency, displayWidth, formatPercentRaw, truncateToDisplayWidth } from "../../../utils/format"; import { CompactRangeBar, FundamentalsGrid, PositionTable, QuoteBook } from "./overview/components"; import { buildOverviewStats, buildPositionRows } from "./overview/model"; import { describeFundamentalMarketCap } from "../../../utils/market-capitalization"; import { liveFiftyTwoWeekRange, liveMarketCapitalization } from "../portfolio-list/live-valuation"; interface OverviewTabProps { width?: number; focused?: boolean; ticker: TickerRecord | null; financials: TickerFinancials | null; /** A click on the price chart opens the full chart. */ onOpenChart?: () => void; } export function OverviewTab(props: OverviewTabProps) { if (!props.ticker) return ; return ; } function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpenChart }: OverviewTabProps & { ticker: TickerRecord }) { const baseCurrency = useAppSelector((state) => state.config.baseCurrency); const exchangeRatesState = useAppSelector((state) => state.exchangeRates); const { width: termWidth } = useViewport(); const { fractionalViewport = false, nativePaneChrome } = useUiCapabilities(); const quote = financials?.quote; const fundamentals = financials?.fundamentals; const capitalization = liveMarketCapitalization(quote, fundamentals); const profile = financials?.profile; const instrumentType = quote?.instrumentType?.trim() || financials?.quoteMetadata?.instrumentType?.trim() || ticker.metadata.assetCategory; const exchangeRates = useFxRatesMap([ baseCurrency, ticker.metadata.currency, quote?.currency, capitalization?.currency, ...ticker.metadata.positions.map((position) => position.currency), ]); const effectiveExchangeRates = selectEffectiveExchangeRates(exchangeRates, exchangeRatesState); const quoteCurrency = quote?.currency ?? ticker.metadata.currency ?? baseCurrency; const toBase = (value: number, fromCurrency: string) => convertCurrency(value, fromCurrency, baseCurrency, effectiveExchangeRates); const sector = ticker.metadata.sector ?? profile?.sector; const industry = ticker.metadata.industry ?? profile?.industry; const description = profile?.description?.trim(); const listingVenue = exchangeShortName( quote?.listingExchangeName ?? quote?.exchangeName, quote?.listingExchangeFullName ?? quote?.fullExchangeName, ); const routingVenue = exchangeShortName(quote?.routingExchangeName, quote?.routingExchangeFullName); const metadataParts = [ routingVenue && routingVenue !== listingVenue ? `route ${routingVenue}` : "", ].filter((part) => part.length > 0); const contentWidth = Math.max((width || Math.floor(termWidth * 0.5)) - (fractionalViewport ? 2 : 4), 20); const chartWidth = contentWidth; const hasHistory = (financials?.priceHistory?.length ?? 0) > 2; const unknownHistoryBasis = hasUnknownBondHistoryBasis(quote, ticker.metadata.assetCategory, financials?.quoteMetadata?.instrumentType); const historyQuote = unknownHistoryBasis ? undefined : quote; const chartHistory = appendLiveQuotePoint(financials?.priceHistory ?? [], historyQuote); const chartDelta = (chartHistory.at(-1)?.close ?? 0) - (chartHistory[0]?.close ?? 0); const chartTimeZone = resolveExchangeTimeZone( ticker.metadata.exchange || quote?.listingExchangeName || quote?.exchangeName, ); const priceSeries = pricePointsToResolvedSeries(chartHistory, { id: `${ticker.metadata.ticker}:price`, label: `${ticker.metadata.ticker} Price`, color: priceColor(chartDelta), unit: unknownHistoryBasis ? "unknown" : quoteCurrency, style: "area", axis: "right", panelId: "price", timeBasis: chartTimeZone ? { kind: "market", timeZone: chartTimeZone } : undefined, }); usePaneNoticeFooter({ registrationId: "overview-notices", notices: [ ...(!quote && financials ? [t("Current quote unavailable. Other research data is still available.")] : []), ...(priceSeries.warning ? [priceSeries.warning] : []), ...(fundamentals?.unavailableFields?.includes("enterpriseValue") ? [t("Enterprise value unavailable: the source observation failed validation.")] : []), ...(capitalization?.provenance.kind === "fundamentals" && !capitalization.live ? [`Market cap: ${describeFundamentalMarketCap(capitalization.provenance)}.`] : []), ], focused, }); const hasBidAsk = quote?.bid != null || quote?.ask != null; const quoteBookInline = hasBidAsk && contentWidth >= 68; const quoteBookWidth = quoteBookInline ? Math.min(32, Math.max(24, Math.floor(contentWidth * 0.3))) : Math.min(contentWidth, 32); const quoteSummaryWidth = quoteBookInline ? Math.max(20, contentWidth - quoteBookWidth - 2) : contentWidth; // Price, change and ranges keep the instrument's decimals on every streamed tick. const moneyOptions = liveQuoteFormatOptions(quote, quote?.currency, ticker.metadata.assetCategory, financials?.quoteMetadata?.instrumentType); const quotePriceText = quote ? formatMarketPriceWithCurrency(quote.price, quote.currency, moneyOptions) : ""; const quoteChangeText = quote ? formatSignedMarketPrice(quote.change, moneyOptions) : ""; const quotePercentText = quote ? `(${formatPercentRaw(quote.changePercent)})` : ""; const quoteTextWidth = Math.max(1, quoteSummaryWidth - (nativePaneChrome ? 6 : 0)); const stackQuoteChange = displayWidth(quoteChangeText) + 1 + displayWidth(quotePercentText) > quoteTextWidth; const stackQuoteSummary = displayWidth(quotePriceText) + 3 + displayWidth(quoteChangeText) + displayWidth(quotePercentText) > quoteTextWidth; // The pane title already names the ticker, so the line leads with the company. const companyName = ticker.metadata.name || quote?.name || ticker.metadata.ticker; const marketStateText = quote?.marketState ? t(marketStateLabel(quote.marketState)) : ""; const companyNameWidth = Math.max(8, quoteSummaryWidth - (nativePaneChrome ? 6 : 0) - (listingVenue ? listingVenue.length + 3 : 0) - (marketStateText ? marketStateText.length + 1 : 0)); const hasDayRange = quote?.low != null && quote?.high != null && quote.high > quote.low; const yearRange = liveFiftyTwoWeekRange(quote); const hasYearRange = yearRange != null; const rangeInline = contentWidth >= 70 && hasDayRange && hasYearRange; const rangeWidth = rangeInline ? Math.floor((contentWidth - 2) / 2) : contentWidth; const rangeMarkerColor = quote ? priceColor(quote.change) : colors.textDim; const stats = buildOverviewStats({ quote, fundamentals, quoteCurrency, baseCurrency, toBase, marketCapExchangeRates: effectiveExchangeRates, }); const performanceFields = buildPriceReturnFields( appendQuoteToPriceReturnHistory(financials?.priceHistory ?? [], historyQuote), ); const hasPerformance = performanceFields.some((field) => field.value != null || field.unavailableReason); const positionRows = buildPositionRows({ ticker, quote, quoteCurrency, baseCurrency, toBase, }); return ( {truncateToDisplayWidth(companyName, companyNameWidth)} {listingVenue && ( {" "}({listingVenue}) )} {quote?.marketState && ( {" "}{t(marketStateLabel(quote.marketState))} )} {quote && ( {quotePriceText} {quoteChangeText} {quotePercentText} )} {quote && (quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice != null && ( {t("Pre-Market")}: {formatMarketPriceWithCurrency(quote.preMarketPrice, quote.currency, moneyOptions)} {formatSignedMarketPrice(quote.preMarketChange, moneyOptions)} ({formatPercentRaw(quote.preMarketChangePercent)}) )} {quote && (quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice != null && ( {t("After-Hours")}: {formatMarketPriceWithCurrency(quote.postMarketPrice, quote.currency, moneyOptions)} {formatSignedMarketPrice(quote.postMarketChange, moneyOptions)} ({formatPercentRaw(quote.postMarketChangePercent)}) )} {metadataParts.length > 0 && ( {metadataParts.join(" | ")} )} {quote && hasBidAsk && ( )} {(hasDayRange || hasYearRange) && quote && ( {hasDayRange && ( )} {yearRange && ( )} )} {hasHistory && ( { if ((event?.button ?? 0) === 0) onOpenChart(); } : undefined} cursor={onOpenChart ? "pointer" : undefined} data-gloom-interactive={onOpenChart ? "true" : undefined} data-gloom-role="overview-chart" data-gloom-label={onOpenChart ? "Open chart" : undefined} > )} {hasPerformance && ( )} {stats.length > 0 && ( )} {positionRows.length > 0 && ( )} {/* Sector / Industry / Type */} {(sector || industry || instrumentType) && ( {instrumentType && ( {t("Type")}: {instrumentType} )} {sector && ( {t("Sector")}: {sector} )} {industry && ( {t("Industry")}: {industry} )} )} {/* ISIN */} {ticker.metadata.isin && ( {t("ISIN")}: {ticker.metadata.isin} )} {/* Description — last, collapsed */} {description && ( {description} )} ); }