import { usePaneSettingValue } from "../../../public/react"; import { PortfolioRiskPane } from "./risk-pane"; import { portfolioRiskHeadless } from "./risk-headless"; import { portfolioRiskCache } from "./risk-client"; import { Box, Text } from "../../../ui"; import { useCallback, useEffect, useMemo, useState } from "react"; import { TextAttributes } from "../../../ui"; import { EmptyState, SectionHeading, Tabs, usePaneHeaderTabs, usePaneNoticeFooter } from "../../../components"; import type { PaneProps } from "../../../types/plugin"; import type { PluginModule } from "../plugin-module"; import { colors } from "../../../theme/colors"; import { convertCurrency } from "../../../utils/format"; import { getFocusedCollectionId, useAppSelector, usePaneInstance, usePaneStateValue, usePaneAppConfig, } from "../../../state/app/context"; import { useChartQueries, useFxRatesMap } from "../../../market-data/hooks"; import { useLiveTickerFinancialsMap, useSampledValue } from "../../../state/hooks/live-ticker-financials"; import { buildPortfolioFinancialsMap } from "../../../market-data/portfolio-financials"; import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map"; import { usePortfolioAccountState } from "../portfolio-list/summary/live-accounts"; import { calculatePortfolioSummaryTotals, type ColumnContext } from "../portfolio-list/metrics"; import { buildPerformanceChartPoints, performanceHistoryNote, useBrokerPortfolioPerformance, } from "./broker-performance"; import { computeSharpeRatio, hasPortfolioPosition, } from "./metrics"; import { buildAnalyticsRiskRows, buildAnalyticsSummaryRows, buildBenchmarkReturnSeries, buildHistoryAxisLabel, buildPortfolioChartTargets, buildPortfolioReturnSeries, buildPortfolioBetaResult, PORTFOLIO_BENCHMARK, formatHistoryAxisValue, resolvePerformancePalette, } from "./pane-model"; import { buildSectorColumns, buildSectorRowsFromPortfolioColumns, buildTrackedCurrencies, DEFAULT_SECTOR_SORT, nextSectorSortPreference, sortSectorRows, type SectorSortPreference, } from "./sector-model"; import { describePortfolioTab, resolvePortfolioId, resolveTemplatePortfolioId } from "./portfolio-selection"; import { AnalyticsMetricsPanel, PortfolioHistorySection, SectorAllocationTable, } from "./view"; const ANALYTICS_STATS_SAMPLE_MS = 5_000; function LegacyPortfolioAnalyticsPane({ focused, width, height }: PaneProps) { const focusedCollectionId = useAppSelector((state) => getFocusedCollectionId(state)); const portfolios = useAppSelector((state) => state.config.portfolios); const baseCurrency = useAppSelector((state) => state.config.baseCurrency); const tickersBySymbol = useAppSelector((state) => state.tickers); const cachedFinancials = useAppSelector((state) => state.financials); const cachedExchangeRates = useAppSelector((state) => state.exchangeRates); const brokerAccounts = useAppSelector((state) => state.brokerAccounts); const config = usePaneAppConfig(); const paneInstance = usePaneInstance(); const requestedPortfolioId = paneInstance?.params?.portfolioId ?? paneInstance?.params?.collectionId; const fallbackPortfolioId = useMemo( () => ( resolvePortfolioId(portfolios, requestedPortfolioId) ?? resolveTemplatePortfolioId(portfolios, focusedCollectionId) ?? "" ), [focusedCollectionId, portfolios, requestedPortfolioId], ); const [currentPortfolioId, setCurrentPortfolioId] = usePaneStateValue("portfolioId", fallbackPortfolioId); const [selectedSectorId, setSelectedSectorId] = useState(null); const [sectorSort, setSectorSort] = useState(DEFAULT_SECTOR_SORT); const activePortfolioId = resolvePortfolioId(portfolios, currentPortfolioId) ?? fallbackPortfolioId; const activePortfolio = useMemo( () => portfolios.find((portfolio) => portfolio.id === activePortfolioId) ?? null, [activePortfolioId, portfolios], ); const portfolioTabs = useMemo( () => portfolios.map((portfolio) => ({ label: describePortfolioTab(portfolio, config.brokerInstances), value: portfolio.id, })), [config.brokerInstances, portfolios], ); const handlePortfolioSelect = useCallback((portfolioId: string) => { setCurrentPortfolioId(portfolioId); setSelectedSectorId(null); }, [setCurrentPortfolioId]); const tabsInHeader = usePaneHeaderTabs(portfolioTabs.length > 0 ? { tabs: portfolioTabs, activeValue: activePortfolioId, onSelect: handlePortfolioSelect, focused, } : null); const tabRows = tabsInHeader ? 0 : 1; const portfolioTickers = useMemo(() => { if (!activePortfolioId) return []; return [...tickersBySymbol.values()] .filter((ticker) => ticker.metadata.portfolios.includes(activePortfolioId)) .filter((ticker) => hasPortfolioPosition(ticker, activePortfolioId)); }, [activePortfolioId, tickersBySymbol]); const instrumentOptions = useMemo(() => ({ portfolioId: activePortfolioId || undefined, }), [activePortfolioId]); const chartTargets = useMemo( () => buildPortfolioChartTargets(portfolioTickers, instrumentOptions), [portfolioTickers, instrumentOptions], ); const chartRequests = useMemo( () => chartTargets.flatMap((target) => target.request ? [target.request] : []), [chartTargets], ); const chartEntries = useChartQueries(chartRequests); const spyRequest = useMemo( () => ({ instrument: { symbol: PORTFOLIO_BENCHMARK.symbol, exchange: PORTFOLIO_BENCHMARK.exchange }, bufferRange: "1Y" as const, granularity: "range" as const, }), [], ); const spyChartRequests = useMemo(() => [spyRequest], [spyRequest]); const spyChartEntries = useChartQueries(spyChartRequests); // Totals and weights are aggregates, so the positions stream as background // targets (about once a second each) and merge with the portfolio pane's rows. const marketFinancials = useLiveTickerFinancialsMap(portfolioTickers, { surface: "portfolio", visible: false, weight: 20, instrumentOptions, }); const financials = useMemo( () => buildPortfolioFinancialsMap(portfolioTickers, cachedFinancials, marketFinancials, instrumentOptions), [portfolioTickers, cachedFinancials, marketFinancials, instrumentOptions], ); // Sharpe and beta weight daily returns by position value; they are daily // statistics and do not need to be recomputed on every tick. const statsFinancials = useSampledValue( financials, ANALYTICS_STATS_SAMPLE_MS, `${activePortfolioId}\u001f${[...financials.keys()].join(",")}`, ); const brokerPerformance = useBrokerPortfolioPerformance(activePortfolio, config); const performanceChartPoints = useMemo( () => buildPerformanceChartPoints(brokerPerformance.performance), [brokerPerformance.performance], ); const accountStateInput = useMemo(() => ({ brokerAccounts, config }), [brokerAccounts, config]); const { accountState, accountsError } = usePortfolioAccountState(activePortfolio, accountStateInput); const trackedCurrencies = useMemo( () => [...buildTrackedCurrencies(portfolioTickers, financials, baseCurrency), accountState?.account.currency], [accountState?.account.currency, baseCurrency, financials, portfolioTickers], ); const fetchedExchangeRates = useFxRatesMap(trackedCurrencies); const effectiveExchangeRates = selectEffectiveExchangeRates(fetchedExchangeRates, cachedExchangeRates); const columnContext = useMemo(() => ({ activeTab: activePortfolioId || undefined, baseCurrency, exchangeRates: effectiveExchangeRates, now: Date.now(), }), [activePortfolioId, baseCurrency, effectiveExchangeRates]); const portfolioStats = useMemo( () => calculatePortfolioSummaryTotals( portfolioTickers, financials, baseCurrency, effectiveExchangeRates, true, activePortfolioId || null, ), [activePortfolioId, baseCurrency, effectiveExchangeRates, financials, portfolioTickers], ); const returnSeriesResult = useMemo( () => buildPortfolioReturnSeries({ chartTargets, chartEntries, financials: statsFinancials, columnContext, account: accountState?.account, }), [accountState, chartEntries, chartTargets, columnContext, statsFinancials], ); const portfolioReturnSeries = returnSeriesResult.returns; const portfolioReturns = useMemo( () => portfolioReturnSeries?.map((point) => point.value) ?? null, [portfolioReturnSeries], ); const spyReturnSeries = useMemo( () => buildBenchmarkReturnSeries(spyRequest, spyChartEntries), [spyChartEntries, spyRequest], ); const sharpe = useMemo( () => (portfolioReturns && returnSeriesResult.sharpeCadence.supported ? computeSharpeRatio(portfolioReturns) : null), [portfolioReturns, returnSeriesResult.sharpeCadence], ); const betaResult = useMemo( () => buildPortfolioBetaResult(returnSeriesResult, spyReturnSeries), [returnSeriesResult, spyReturnSeries], ); const beta = betaResult.value; const sectorAllocation = useMemo( () => buildSectorRowsFromPortfolioColumns(portfolioTickers, financials, columnContext), [columnContext, financials, portfolioTickers], ); const allocationNotices = [ ...(sectorAllocation.unvaluedSymbols.length > 0 ? [{ text: `Weights unavailable: missing prices or FX for ${sectorAllocation.unvaluedSymbols.join(", ")}.`, tone: "warning" as const, }] : []), ]; const sectorRows = sectorAllocation.rows; const sortedSectorRows = useMemo( () => sortSectorRows(sectorRows, sectorSort), [sectorRows, sectorSort], ); const effectiveSelectedSectorId = selectedSectorId && sortedSectorRows.some((row) => row.id === selectedSectorId) ? selectedSectorId : sortedSectorRows[0]?.id ?? null; const sectorColumns = useMemo(() => buildSectorColumns(width), [width]); const hasPositions = portfolioTickers.length > 0; const hasAccountContent = accountState != null || brokerPerformance.performance != null || brokerPerformance.loading || brokerPerformance.error != null; const summaryRows = useMemo( () => buildAnalyticsSummaryRows({ accountState, activePortfolio, brokerPerformance: brokerPerformance.performance, portfolioStats, convertAccountValue: (value) => convertCurrency( value, accountState?.account.currency ?? "", baseCurrency, effectiveExchangeRates, ), }), [accountState, activePortfolio, baseCurrency, brokerPerformance.performance, effectiveExchangeRates, portfolioStats], ); const riskRows = useMemo( () => hasPositions ? buildAnalyticsRiskRows({ sharpe, beta, coverage: returnSeriesResult.coverage, missingCount: returnSeriesResult.missingCount, unvaluedCount: returnSeriesResult.unvaluedCount, unsupportedReason: returnSeriesResult.unsupportedReason, historyIntegrity: returnSeriesResult.historyIntegrity, benchmarkIntegrity: spyReturnSeries.integrity, sharpeCadence: returnSeriesResult.sharpeCadence, returnTimestamps: returnSeriesResult.returnTimestamps, betaHoldingTimestamps: betaResult.holdingTimestamps, benchmarkTimestamps: betaResult.benchmarkTimestamps, returns: portfolioReturnSeries, benchmarkReturns: spyReturnSeries.returns, }) : [], [beta, betaResult, hasPositions, returnSeriesResult, spyReturnSeries, sharpe], ); const metricsHeight = summaryRows.length === 0 && riskRows.length === 0 ? 0 : summaryRows.length + (riskRows.length > 0 ? riskRows.length + 5 : 3); const historyNote = performanceHistoryNote(brokerPerformance.performance); usePaneNoticeFooter({ registrationId: "analytics:data-notices", notices: [...allocationNotices.map((notice) => notice.text), ...(historyNote ? [historyNote] : [])], focused, enabled: hasPositions || hasAccountContent, title: "Portfolio data", }); const availableHistoryChartHeight = height - metricsHeight - 6 - tabRows; const historyChartHeight = performanceChartPoints.filter((point) => Number.isFinite(point.close)).length >= 2 && availableHistoryChartHeight >= 5 ? Math.min(8, availableHistoryChartHeight) : 0; const showHistoryChart = historyChartHeight >= 5; const performancePalette = useMemo( () => resolvePerformancePalette(brokerPerformance.performance), [brokerPerformance.performance], ); const historyAxisLabel = buildHistoryAxisLabel({ performance: brokerPerformance.performance, }); const formatHistoryAxis = useCallback((value: number) => ( formatHistoryAxisValue(value, brokerPerformance.performance) ), [brokerPerformance.performance]); const handleSectorHeaderClick = useCallback((columnId: string) => { setSectorSort((current) => nextSectorSortPreference(current, columnId)); }, []); useEffect(() => { if (activePortfolioId !== currentPortfolioId) { setCurrentPortfolioId(activePortfolioId); } }, [activePortfolioId, currentPortfolioId, setCurrentPortfolioId]); return ( {portfolioTabs.length === 0 ? ( No portfolios found ) : ( <> {!tabsInHeader && ( )} {!hasPositions && !hasAccountContent ? ( ) : ( <> {metricsHeight > 0 && } {hasPositions && ( <> )} )} )} ); } function PortfolioAnalyticsPane(props: PaneProps) { const [view] = usePaneSettingValue("analyticsView", "overview"); return view === "risk" ? : ; } export const portfolioAnalyticsModule: PluginModule = { panes: [ { id: "analytics", name: "Portfolio Analytics", icon: "R", component: PortfolioAnalyticsPane, headless: portfolioRiskHeadless, tableExport: true, settings: { fields: [ { key: "analyticsView", label: "View", type: "select", options: [{ value: "overview", label: "Overview" }, { value: "risk", label: "Risk depth" }] }, { key: "riskEvidence", label: "Local evidence JSON", type: "text" }, { key: "equityShift", label: "Index shift (%)", type: "text" }, { key: "rateShift", label: "10Y shift (bp)", type: "text" }, { key: "volShift", label: "VIX shift (points)", type: "text" }, ] }, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 80, height: 30 }, portableShare: { private: { params: true, settings: true, state: true }, }, }, ], setup(ctx) { portfolioRiskCache.attach(ctx.persistence); }, dispose() { portfolioRiskCache.reset(); }, paneTemplates: ["PORT", "MARS"].map(prefix => ({ id: prefix === "PORT" ? "analytics-pane" : "analytics-mars-pane", paneId: "analytics", label: prefix === "PORT" ? "Portfolio Analytics" : "Portfolio Market Risk", description: "Benchmark-relative risk, factor betas, stress shifts and local account evidence.", keywords: ["risk", "analytics", "sharpe", "beta", "sector", "allocation", "portfolio"], shortcut: { prefix, argKind: "text", argOptional: true, argPlaceholder: "portfolio-id" }, headless: portfolioRiskHeadless, canCreate: (context) => context.config.portfolios.length > 0, createInstance: (context, options) => { const portfolioId = resolvePortfolioId(context.config.portfolios, options?.arg) ?? resolveTemplatePortfolioId(context.config.portfolios, context.activeCollectionId); return portfolioId ? { params: { portfolioId }, settings: { analyticsView: "risk" } } : null; }, })), };