gloomberb 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -0,0 +1,775 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { DataTable, TabBar, type DataTableColumn } from "../../../components";
5
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
+ import type { ColumnConfig } from "../../../types/config";
7
+ import type { TickerFinancials } from "../../../types/financials";
8
+ import type { Portfolio, TickerRecord } from "../../../types/ticker";
9
+ import { colors, priceColor } from "../../../theme/colors";
10
+ import { formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
11
+ import {
12
+ getFocusedCollectionId,
13
+ useAppSelector,
14
+ usePaneInstance,
15
+ usePaneStateValue,
16
+ } from "../../../state/app-context";
17
+ import { getCollectionTickers } from "../../../state/selectors";
18
+ import { useChartQueries, useFxRatesMap, useTickerFinancialsMap } from "../../../market-data/hooks";
19
+ import { instrumentFromTicker, type ChartRequest } from "../../../market-data/request-types";
20
+ import { buildChartKey } from "../../../market-data/selectors";
21
+ import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
22
+ import { usePortfolioAccountState } from "../portfolio-list/header";
23
+ import { calculatePortfolioSummaryTotals, getSortValue, type ColumnContext } from "../portfolio-list/metrics";
24
+ import {
25
+ computeDatedBeta,
26
+ computeDatedReturns,
27
+ computeSharpeRatio,
28
+ computeWeightedPortfolioReturns,
29
+ hasPortfolioPosition,
30
+ type WeightedReturnSeries,
31
+ } from "./metrics";
32
+
33
+ type SectorColumnId = "sector" | "weight" | "value" | "pnl" | "return" | "bar";
34
+
35
+ interface SectorTableColumn extends DataTableColumn {
36
+ id: SectorColumnId;
37
+ }
38
+
39
+ interface SectorTableRow {
40
+ id: string;
41
+ sector: string;
42
+ weight: number;
43
+ value: number;
44
+ pnl: number;
45
+ returnPct: number | null;
46
+ costBasis: number;
47
+ }
48
+
49
+ interface AnalyticsMetricRow {
50
+ id: string;
51
+ label: string;
52
+ value: string;
53
+ detail?: string;
54
+ color?: string;
55
+ }
56
+
57
+ interface SectorSortPreference {
58
+ columnId: SectorColumnId | null;
59
+ direction: "asc" | "desc";
60
+ }
61
+
62
+ interface PortfolioChartTarget {
63
+ ticker: TickerRecord;
64
+ request: ChartRequest;
65
+ }
66
+
67
+ const DEFAULT_SECTOR_SORT: SectorSortPreference = {
68
+ columnId: "weight",
69
+ direction: "desc",
70
+ };
71
+
72
+ const PORTFOLIO_VALUE_COLUMN: ColumnConfig = { id: "mkt_value", label: "VALUE", width: 10, align: "right" };
73
+ const PORTFOLIO_PNL_COLUMN: ColumnConfig = { id: "pnl", label: "P&L", width: 10, align: "right" };
74
+ const PORTFOLIO_COST_COLUMN: ColumnConfig = { id: "cost_basis", label: "COST", width: 10, align: "right" };
75
+
76
+ function sharpeColor(sharpe: number): string {
77
+ if (sharpe > 1) return colors.positive;
78
+ if (sharpe < 0) return colors.negative;
79
+ return colors.textDim;
80
+ }
81
+
82
+ function sharpeLabel(sharpe: number): string {
83
+ if (sharpe > 1) return "good";
84
+ if (sharpe >= 0) return "okay";
85
+ return "poor";
86
+ }
87
+
88
+ function betaLabel(beta: number): string {
89
+ if (beta > 1.2) return "high vol";
90
+ if (beta >= 0.8) return "market";
91
+ return "defensive";
92
+ }
93
+
94
+ function betaColor(beta: number): string {
95
+ if (beta > 1.2) return colors.negative;
96
+ if (beta >= 0.8) return colors.textMuted ?? colors.text;
97
+ return colors.positive;
98
+ }
99
+
100
+ function renderBar(weight: number, maxWidth: number): string {
101
+ const filled = Math.round(weight * maxWidth);
102
+ return "█".repeat(Math.min(filled, maxWidth));
103
+ }
104
+
105
+ function resolvePortfolioId(portfolios: Portfolio[], portfolioId: string | null | undefined): string | null {
106
+ if (!portfolioId) return null;
107
+ return portfolios.some((portfolio) => portfolio.id === portfolioId) ? portfolioId : null;
108
+ }
109
+
110
+ function resolveTemplatePortfolioId(portfolios: Portfolio[], activeCollectionId: string | null): string | null {
111
+ return resolvePortfolioId(portfolios, activeCollectionId) ?? portfolios[0]?.id ?? null;
112
+ }
113
+
114
+ function formatSignedCompact(value: number): string {
115
+ return `${value >= 0 ? "+" : ""}${formatCompact(value)}`;
116
+ }
117
+
118
+ function formatWeight(weight: number): string {
119
+ return `${(weight * 100).toFixed(1)}%`;
120
+ }
121
+
122
+ function buildSectorColumns(width: number): SectorTableColumn[] {
123
+ const sectorWidth = Math.max(12, Math.min(22, Math.floor(width * 0.28)));
124
+ const weightWidth = 8;
125
+ const valueWidth = 10;
126
+ const pnlWidth = 10;
127
+ const returnWidth = 8;
128
+ const barWidth = Math.max(8, width - sectorWidth - weightWidth - valueWidth - pnlWidth - returnWidth - 10);
129
+
130
+ return [
131
+ { id: "sector", label: "SECTOR", width: sectorWidth, align: "left" },
132
+ { id: "weight", label: "WEIGHT", width: weightWidth, align: "right" },
133
+ { id: "value", label: "VALUE", width: valueWidth, align: "right" },
134
+ { id: "pnl", label: "P&L", width: pnlWidth, align: "right" },
135
+ { id: "return", label: "RETURN", width: returnWidth, align: "right" },
136
+ { id: "bar", label: "ALLOCATION", width: barWidth, align: "left" },
137
+ ];
138
+ }
139
+
140
+ function getSectorSortValue(row: SectorTableRow, columnId: SectorColumnId): string | number {
141
+ switch (columnId) {
142
+ case "sector":
143
+ return row.sector;
144
+ case "value":
145
+ return row.value;
146
+ case "pnl":
147
+ return row.pnl;
148
+ case "return":
149
+ return row.returnPct ?? Number.NEGATIVE_INFINITY;
150
+ case "bar":
151
+ case "weight":
152
+ return row.weight;
153
+ }
154
+ }
155
+
156
+ function buildTrackedCurrencies(
157
+ tickers: TickerRecord[],
158
+ financialsMap: Map<string, TickerFinancials>,
159
+ baseCurrency: string,
160
+ ): string[] {
161
+ const currencies = new Set<string>([baseCurrency]);
162
+
163
+ for (const ticker of tickers) {
164
+ if (ticker.metadata.currency) {
165
+ currencies.add(ticker.metadata.currency);
166
+ }
167
+ for (const position of ticker.metadata.positions) {
168
+ if (position.currency) {
169
+ currencies.add(position.currency);
170
+ }
171
+ }
172
+ const financials = financialsMap.get(ticker.metadata.ticker);
173
+ if (financials?.quote?.currency) {
174
+ currencies.add(financials.quote.currency);
175
+ }
176
+ }
177
+
178
+ return [...currencies];
179
+ }
180
+
181
+ function buildSectorRowsFromPortfolioColumns(
182
+ tickers: TickerRecord[],
183
+ financialsMap: Map<string, TickerFinancials>,
184
+ columnContext: ColumnContext,
185
+ ): SectorTableRow[] {
186
+ const sectorMap = new Map<string, {
187
+ sector: string;
188
+ value: number;
189
+ pnl: number;
190
+ costBasis: number;
191
+ }>();
192
+
193
+ for (const ticker of tickers) {
194
+ const financials = financialsMap.get(ticker.metadata.ticker);
195
+ const value = getSortValue(PORTFOLIO_VALUE_COLUMN, ticker, financials, columnContext);
196
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;
197
+
198
+ const pnl = getSortValue(PORTFOLIO_PNL_COLUMN, ticker, financials, columnContext);
199
+ const costBasis = getSortValue(PORTFOLIO_COST_COLUMN, ticker, financials, columnContext);
200
+ const sector = ticker.metadata.sector || financials?.profile?.sector || "Unknown";
201
+ const current = sectorMap.get(sector) ?? {
202
+ sector,
203
+ value: 0,
204
+ pnl: 0,
205
+ costBasis: 0,
206
+ };
207
+
208
+ current.value += value;
209
+ current.pnl += typeof pnl === "number" && Number.isFinite(pnl) ? pnl : 0;
210
+ current.costBasis += typeof costBasis === "number" && Number.isFinite(costBasis) ? costBasis : 0;
211
+ sectorMap.set(sector, current);
212
+ }
213
+
214
+ const totalValue = [...sectorMap.values()].reduce((sum, row) => sum + row.value, 0);
215
+ if (totalValue === 0) return [];
216
+
217
+ return [...sectorMap.values()]
218
+ .map((row) => ({
219
+ ...row,
220
+ id: row.sector,
221
+ weight: row.value / totalValue,
222
+ returnPct: row.costBasis !== 0 ? (row.pnl / row.costBasis) * 100 : null,
223
+ }))
224
+ .sort((left, right) => right.weight - left.weight || left.sector.localeCompare(right.sector));
225
+ }
226
+
227
+ function sortSectorRows(rows: SectorTableRow[], sort: SectorSortPreference): SectorTableRow[] {
228
+ const columnId = sort.columnId;
229
+ if (!columnId) return rows;
230
+
231
+ return [...rows].sort((left, right) => {
232
+ const leftValue = getSectorSortValue(left, columnId);
233
+ const rightValue = getSectorSortValue(right, columnId);
234
+ const comparison = typeof leftValue === "string" && typeof rightValue === "string"
235
+ ? leftValue.localeCompare(rightValue)
236
+ : (leftValue as number) - (rightValue as number);
237
+ return sort.direction === "asc" ? comparison : -comparison;
238
+ });
239
+ }
240
+
241
+ function nextSectorSortPreference(current: SectorSortPreference, columnId: string): SectorSortPreference {
242
+ const nextColumnId = columnId as SectorColumnId;
243
+ if (current.columnId !== nextColumnId) {
244
+ return { columnId: nextColumnId, direction: "asc" };
245
+ }
246
+ if (current.direction === "asc") {
247
+ return { columnId: nextColumnId, direction: "desc" };
248
+ }
249
+ return { columnId: null, direction: "asc" };
250
+ }
251
+
252
+ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
253
+ const state = useAppSelector((s) => s);
254
+ const paneInstance = usePaneInstance();
255
+ const focusedCollectionId = getFocusedCollectionId(state);
256
+ const portfolios = state.config.portfolios;
257
+ const requestedPortfolioId = paneInstance?.params?.portfolioId ?? paneInstance?.params?.collectionId;
258
+ const fallbackPortfolioId = useMemo(
259
+ () => (
260
+ resolvePortfolioId(portfolios, requestedPortfolioId)
261
+ ?? resolveTemplatePortfolioId(portfolios, focusedCollectionId)
262
+ ?? ""
263
+ ),
264
+ [focusedCollectionId, portfolios, requestedPortfolioId],
265
+ );
266
+
267
+ const [currentPortfolioId, setCurrentPortfolioId] = usePaneStateValue<string>("portfolioId", fallbackPortfolioId);
268
+ const [selectedSectorId, setSelectedSectorId] = useState<string | null>(null);
269
+ const [hoveredSectorIdx, setHoveredSectorIdx] = useState<number | null>(null);
270
+ const [sectorSort, setSectorSort] = useState<SectorSortPreference>(DEFAULT_SECTOR_SORT);
271
+
272
+ const sectorScrollRef = useRef<ScrollBoxRenderable>(null);
273
+ const sectorHeaderScrollRef = useRef<ScrollBoxRenderable>(null);
274
+
275
+ const activePortfolioId = resolvePortfolioId(portfolios, currentPortfolioId) ?? fallbackPortfolioId;
276
+ const activePortfolio = useMemo(
277
+ () => portfolios.find((portfolio) => portfolio.id === activePortfolioId) ?? null,
278
+ [activePortfolioId, portfolios],
279
+ );
280
+ const activePortfolioIndex = portfolios.findIndex((portfolio) => portfolio.id === activePortfolioId);
281
+ const portfolioTabs = useMemo(
282
+ () => portfolios.map((portfolio) => ({ label: portfolio.name, value: portfolio.id })),
283
+ [portfolios],
284
+ );
285
+
286
+ const handlePortfolioSelect = useCallback((portfolioId: string) => {
287
+ setCurrentPortfolioId(portfolioId);
288
+ setSelectedSectorId(null);
289
+ setHoveredSectorIdx(null);
290
+ sectorScrollRef.current?.scrollTo(0);
291
+ }, [setCurrentPortfolioId]);
292
+
293
+ const portfolioTickers = useMemo(() => {
294
+ if (!activePortfolioId) return [];
295
+ return getCollectionTickers(state, activePortfolioId)
296
+ .filter((ticker) => hasPortfolioPosition(ticker, activePortfolioId));
297
+ }, [activePortfolioId, state]);
298
+
299
+ const chartTargets = useMemo<PortfolioChartTarget[]>(
300
+ () => portfolioTickers.flatMap((ticker) => {
301
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
302
+ if (!instrument) return [];
303
+ return [{
304
+ ticker,
305
+ request: {
306
+ instrument,
307
+ bufferRange: "1Y" as const,
308
+ granularity: "range" as const,
309
+ },
310
+ }];
311
+ }),
312
+ [portfolioTickers],
313
+ );
314
+ const chartRequests = useMemo(
315
+ () => chartTargets.map((target) => target.request),
316
+ [chartTargets],
317
+ );
318
+ const chartEntries = useChartQueries(chartRequests);
319
+
320
+ const spyRequest = useMemo(
321
+ () => ({
322
+ instrument: { symbol: "SPY", exchange: "" },
323
+ bufferRange: "1Y" as const,
324
+ granularity: "range" as const,
325
+ }),
326
+ [],
327
+ );
328
+ const spyChartRequests = useMemo(() => [spyRequest], [spyRequest]);
329
+ const spyChartEntries = useChartQueries(spyChartRequests);
330
+
331
+ const marketFinancials = useTickerFinancialsMap(portfolioTickers);
332
+ const financials = useMemo(() => {
333
+ const merged = new Map(state.financials);
334
+ for (const [symbol, data] of marketFinancials) {
335
+ merged.set(symbol, data);
336
+ }
337
+ return merged;
338
+ }, [marketFinancials, state.financials]);
339
+ const accountState = usePortfolioAccountState(activePortfolio, state);
340
+ const trackedCurrencies = useMemo(
341
+ () => buildTrackedCurrencies(portfolioTickers, financials, state.config.baseCurrency),
342
+ [financials, portfolioTickers, state.config.baseCurrency],
343
+ );
344
+ const fetchedExchangeRates = useFxRatesMap(trackedCurrencies);
345
+ const effectiveExchangeRates = selectEffectiveExchangeRates(fetchedExchangeRates, state.exchangeRates);
346
+ const columnContext = useMemo<ColumnContext>(() => ({
347
+ activeTab: activePortfolioId || undefined,
348
+ baseCurrency: state.config.baseCurrency,
349
+ exchangeRates: effectiveExchangeRates,
350
+ now: Date.now(),
351
+ }), [activePortfolioId, effectiveExchangeRates, state.config.baseCurrency]);
352
+
353
+ const portfolioStats = useMemo(
354
+ () => calculatePortfolioSummaryTotals(
355
+ portfolioTickers,
356
+ financials,
357
+ state.config.baseCurrency,
358
+ effectiveExchangeRates,
359
+ true,
360
+ activePortfolioId || null,
361
+ ),
362
+ [activePortfolioId, effectiveExchangeRates, financials, portfolioTickers, state.config.baseCurrency],
363
+ );
364
+
365
+ const portfolioReturnSeries = useMemo(() => {
366
+ const weightedSeries: WeightedReturnSeries[] = [];
367
+ for (const { ticker, request } of chartTargets) {
368
+ const key = buildChartKey(request);
369
+ const entry = chartEntries.get(key);
370
+ const history = entry?.data ?? entry?.lastGoodData ?? null;
371
+ if (!history || history.length < 11) continue;
372
+
373
+ const returns = computeDatedReturns(history);
374
+ if (returns.length < 10) continue;
375
+
376
+ const value = getSortValue(PORTFOLIO_VALUE_COLUMN, ticker, financials.get(ticker.metadata.ticker), columnContext);
377
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;
378
+ weightedSeries.push({ weight: value, returns });
379
+ }
380
+ const returns = computeWeightedPortfolioReturns(weightedSeries);
381
+ return returns.length > 0 ? returns : null;
382
+ }, [chartEntries, chartTargets, columnContext, financials]);
383
+
384
+ const portfolioReturns = useMemo(
385
+ () => portfolioReturnSeries?.map((point) => point.value) ?? null,
386
+ [portfolioReturnSeries],
387
+ );
388
+
389
+ const spyReturnSeries = useMemo(() => {
390
+ const spyKey = buildChartKey(spyRequest);
391
+ const entry = spyChartEntries.get(spyKey);
392
+ const history = entry?.data ?? entry?.lastGoodData ?? null;
393
+ if (!history || history.length < 11) return null;
394
+ const returns = computeDatedReturns(history);
395
+ return returns.length > 0 ? returns : null;
396
+ }, [spyChartEntries, spyRequest]);
397
+
398
+ const sharpe = useMemo(
399
+ () => (portfolioReturns ? computeSharpeRatio(portfolioReturns) : null),
400
+ [portfolioReturns],
401
+ );
402
+
403
+ const beta = useMemo(
404
+ () => (portfolioReturnSeries && spyReturnSeries ? computeDatedBeta(portfolioReturnSeries, spyReturnSeries) : null),
405
+ [portfolioReturnSeries, spyReturnSeries],
406
+ );
407
+
408
+ const sectorRows = useMemo<SectorTableRow[]>(
409
+ () => buildSectorRowsFromPortfolioColumns(portfolioTickers, financials, columnContext),
410
+ [columnContext, financials, portfolioTickers],
411
+ );
412
+ const sortedSectorRows = useMemo(
413
+ () => sortSectorRows(sectorRows, sectorSort),
414
+ [sectorRows, sectorSort],
415
+ );
416
+ const effectiveSelectedSectorId = selectedSectorId && sortedSectorRows.some((row) => row.id === selectedSectorId)
417
+ ? selectedSectorId
418
+ : sortedSectorRows[0]?.id ?? null;
419
+ const selectedSectorIdx = sortedSectorRows.findIndex((row) => row.id === effectiveSelectedSectorId);
420
+ const safeSelectedSectorIdx = selectedSectorIdx >= 0 ? selectedSectorIdx : 0;
421
+ const sectorColumns = useMemo(() => buildSectorColumns(width), [width]);
422
+ const hasPositions = portfolioTickers.length > 0;
423
+
424
+ const summaryRows = useMemo<AnalyticsMetricRow[]>(() => {
425
+ const rows: AnalyticsMetricRow[] = [];
426
+ const account = accountState?.account;
427
+
428
+ if (account?.netLiquidation != null) {
429
+ rows.push({
430
+ id: "net-liquidation",
431
+ label: "Net Liq",
432
+ value: formatCompact(account.netLiquidation),
433
+ color: colors.text,
434
+ });
435
+ }
436
+
437
+ rows.push({
438
+ id: "total-value",
439
+ label: "Val",
440
+ value: formatCompact(portfolioStats.totalMktValue),
441
+ color: colors.text,
442
+ });
443
+
444
+ if (account?.totalCashValue != null) {
445
+ rows.push({
446
+ id: "cash",
447
+ label: "Cash",
448
+ value: formatCompact(account.totalCashValue),
449
+ color: colors.text,
450
+ });
451
+ }
452
+
453
+ rows.push({
454
+ id: "day-pnl",
455
+ label: "Day",
456
+ value: formatSignedCompact(portfolioStats.dailyPnl),
457
+ detail: `(${formatPercentRaw(portfolioStats.dailyPnlPct)})`,
458
+ color: priceColor(portfolioStats.dailyPnl),
459
+ });
460
+ rows.push({
461
+ id: "pnl",
462
+ label: "P&L",
463
+ value: formatSignedCompact(portfolioStats.unrealizedPnl),
464
+ detail: `(${formatPercentRaw(portfolioStats.unrealizedPnlPct)})`,
465
+ color: priceColor(portfolioStats.unrealizedPnl),
466
+ });
467
+
468
+ if (account?.settledCash != null) {
469
+ rows.push({
470
+ id: "settled-cash",
471
+ label: "Settled",
472
+ value: formatCompact(account.settledCash),
473
+ color: colors.text,
474
+ });
475
+ }
476
+ if (account?.availableFunds != null) {
477
+ rows.push({
478
+ id: "available-funds",
479
+ label: "Avail",
480
+ value: formatCompact(account.availableFunds),
481
+ color: colors.text,
482
+ });
483
+ }
484
+ if (account?.excessLiquidity != null) {
485
+ rows.push({
486
+ id: "excess-liquidity",
487
+ label: "Excess",
488
+ value: formatCompact(account.excessLiquidity),
489
+ color: colors.text,
490
+ });
491
+ }
492
+ if (account?.buyingPower != null) {
493
+ rows.push({
494
+ id: "buying-power",
495
+ label: "BP",
496
+ value: formatCompact(account.buyingPower),
497
+ color: colors.text,
498
+ });
499
+ }
500
+ if (accountState) {
501
+ rows.push({
502
+ id: "account-source",
503
+ label: "Source",
504
+ value: accountState.sourceLabel,
505
+ color: colors.textDim,
506
+ });
507
+ }
508
+
509
+ return rows;
510
+ }, [
511
+ accountState,
512
+ portfolioStats.dailyPnl,
513
+ portfolioStats.dailyPnlPct,
514
+ portfolioStats.totalMktValue,
515
+ portfolioStats.unrealizedPnl,
516
+ portfolioStats.unrealizedPnlPct,
517
+ ]);
518
+
519
+ const riskRows = useMemo<AnalyticsMetricRow[]>(() => [
520
+ sharpe !== null
521
+ ? {
522
+ id: "sharpe",
523
+ label: "Sharpe Ratio",
524
+ value: formatNumber(sharpe, 2),
525
+ detail: sharpeLabel(sharpe),
526
+ color: sharpeColor(sharpe),
527
+ }
528
+ : {
529
+ id: "sharpe",
530
+ label: "Sharpe Ratio",
531
+ value: "—",
532
+ detail: "insufficient data",
533
+ color: colors.textMuted,
534
+ },
535
+ beta !== null
536
+ ? {
537
+ id: "beta",
538
+ label: "Beta (SPY)",
539
+ value: formatNumber(beta, 2),
540
+ detail: betaLabel(beta),
541
+ color: betaColor(beta),
542
+ }
543
+ : {
544
+ id: "beta",
545
+ label: "Beta (SPY)",
546
+ value: "—",
547
+ detail: "insufficient data",
548
+ color: colors.textMuted,
549
+ },
550
+ ], [beta, sharpe]);
551
+ const metricsHeight = summaryRows.length + riskRows.length + 5;
552
+
553
+ const syncSectorHeaderScroll = useCallback(() => {
554
+ const bodyScrollBox = sectorScrollRef.current;
555
+ const headerScrollBox = sectorHeaderScrollRef.current;
556
+ if (bodyScrollBox && headerScrollBox && headerScrollBox.scrollLeft !== bodyScrollBox.scrollLeft) {
557
+ headerScrollBox.scrollLeft = bodyScrollBox.scrollLeft;
558
+ }
559
+ }, []);
560
+
561
+ const handleSectorBodyScrollActivity = useCallback(() => {
562
+ syncSectorHeaderScroll();
563
+ }, [syncSectorHeaderScroll]);
564
+
565
+ const handleSectorHeaderClick = useCallback((columnId: string) => {
566
+ setSectorSort((current) => nextSectorSortPreference(current, columnId));
567
+ }, []);
568
+
569
+ useKeyboard((event) => {
570
+ if (!focused) return;
571
+
572
+ const key = event.name;
573
+ if ((key === "h" || key === "left") && activePortfolioIndex > 0) {
574
+ const previousPortfolio = portfolios[activePortfolioIndex - 1];
575
+ if (previousPortfolio) handlePortfolioSelect(previousPortfolio.id);
576
+ return;
577
+ }
578
+ if ((key === "l" || key === "right") && activePortfolioIndex >= 0 && activePortfolioIndex < portfolios.length - 1) {
579
+ const nextPortfolio = portfolios[activePortfolioIndex + 1];
580
+ if (nextPortfolio) handlePortfolioSelect(nextPortfolio.id);
581
+ return;
582
+ }
583
+ if (key === "j" || key === "down") {
584
+ const nextRow = sortedSectorRows[Math.min(safeSelectedSectorIdx + 1, sortedSectorRows.length - 1)];
585
+ if (nextRow) setSelectedSectorId(nextRow.id);
586
+ return;
587
+ }
588
+ if (key === "k" || key === "up") {
589
+ const nextRow = sortedSectorRows[Math.max(safeSelectedSectorIdx - 1, 0)];
590
+ if (nextRow) setSelectedSectorId(nextRow.id);
591
+ }
592
+ });
593
+
594
+ useEffect(() => {
595
+ if (activePortfolioId !== currentPortfolioId) {
596
+ setCurrentPortfolioId(activePortfolioId);
597
+ }
598
+ }, [activePortfolioId, currentPortfolioId, setCurrentPortfolioId]);
599
+
600
+ useEffect(() => {
601
+ if (sectorHeaderScrollRef.current) {
602
+ sectorHeaderScrollRef.current.horizontalScrollBar.visible = false;
603
+ }
604
+ syncSectorHeaderScroll();
605
+ }, [syncSectorHeaderScroll]);
606
+
607
+ useEffect(() => {
608
+ const scrollBox = sectorScrollRef.current;
609
+ if (!scrollBox || selectedSectorIdx < 0) return;
610
+
611
+ const viewportHeight = scrollBox.viewport.height;
612
+ if (selectedSectorIdx < scrollBox.scrollTop) {
613
+ scrollBox.scrollTo(selectedSectorIdx);
614
+ } else if (selectedSectorIdx >= scrollBox.scrollTop + viewportHeight) {
615
+ scrollBox.scrollTo(selectedSectorIdx - viewportHeight + 1);
616
+ }
617
+ }, [selectedSectorIdx]);
618
+
619
+ return (
620
+ <box flexDirection="column" width={width} height={height}>
621
+ {portfolioTabs.length === 0 ? (
622
+ <box paddingX={1} paddingY={1}>
623
+ <text fg={colors.textMuted}>No portfolios found</text>
624
+ </box>
625
+ ) : (
626
+ <>
627
+ <box flexDirection="row" height={1}>
628
+ <box flexShrink={1} overflow="hidden">
629
+ <TabBar
630
+ tabs={portfolioTabs}
631
+ activeValue={activePortfolioId}
632
+ onSelect={handlePortfolioSelect}
633
+ compact
634
+ />
635
+ </box>
636
+ </box>
637
+
638
+ {!hasPositions ? (
639
+ <box paddingX={1} paddingY={1}>
640
+ <text fg={colors.textMuted}>
641
+ No positions found for {activePortfolio?.name ?? "this portfolio"}
642
+ </text>
643
+ </box>
644
+ ) : (
645
+ <>
646
+ <box flexDirection="column" height={metricsHeight} paddingX={1} paddingTop={1}>
647
+ <box height={1}>
648
+ <text fg={colors.textDim} attributes={TextAttributes.BOLD}>
649
+ Summary
650
+ </text>
651
+ </box>
652
+ {summaryRows.map((row) => (
653
+ <box key={row.id} flexDirection="row" height={1}>
654
+ <box width={14} flexShrink={0}>
655
+ <text fg={colors.textDim}>{row.label}</text>
656
+ </box>
657
+ <text fg={row.color ?? colors.text} attributes={TextAttributes.BOLD}>
658
+ {row.value}
659
+ </text>
660
+ {row.detail && <text fg={colors.textDim}>{` ${row.detail}`}</text>}
661
+ </box>
662
+ ))}
663
+
664
+ <box height={1} />
665
+ <box height={1}>
666
+ <text fg={colors.textDim} attributes={TextAttributes.BOLD}>
667
+ Risk / Return
668
+ </text>
669
+ </box>
670
+ {riskRows.map((row) => (
671
+ <box key={row.id} flexDirection="row" height={1}>
672
+ <box width={14} flexShrink={0}>
673
+ <text fg={colors.textDim}>{row.label}</text>
674
+ </box>
675
+ <text fg={row.color ?? colors.text} attributes={TextAttributes.BOLD}>
676
+ {row.value}
677
+ </text>
678
+ {row.detail && <text fg={colors.textDim}>{` ${row.detail}`}</text>}
679
+ </box>
680
+ ))}
681
+ <box height={1} />
682
+ </box>
683
+
684
+ <box height={1} paddingX={1}>
685
+ <text fg={colors.textDim} attributes={TextAttributes.BOLD}>
686
+ Sector Allocation
687
+ </text>
688
+ </box>
689
+
690
+ <DataTable<SectorTableRow, SectorTableColumn>
691
+ columns={sectorColumns}
692
+ items={sortedSectorRows}
693
+ sortColumnId={sectorSort.columnId}
694
+ sortDirection={sectorSort.direction}
695
+ onHeaderClick={handleSectorHeaderClick}
696
+ headerScrollRef={sectorHeaderScrollRef}
697
+ scrollRef={sectorScrollRef}
698
+ syncHeaderScroll={syncSectorHeaderScroll}
699
+ onBodyScrollActivity={handleSectorBodyScrollActivity}
700
+ hoveredIdx={hoveredSectorIdx}
701
+ setHoveredIdx={setHoveredSectorIdx}
702
+ getItemKey={(row) => row.id}
703
+ isSelected={(row) => effectiveSelectedSectorId === row.id}
704
+ onSelect={(row) => setSelectedSectorId(row.id)}
705
+ emptyStateTitle="No sector data available"
706
+ emptyStateHint="Load profile data or add sectors to the portfolio positions."
707
+ renderCell={(row, column) => {
708
+ switch (column.id) {
709
+ case "sector":
710
+ return { text: row.sector };
711
+ case "weight":
712
+ return { text: formatWeight(row.weight) };
713
+ case "value":
714
+ return { text: formatCompact(row.value) };
715
+ case "pnl":
716
+ return {
717
+ text: formatSignedCompact(row.pnl),
718
+ color: priceColor(row.pnl),
719
+ };
720
+ case "return":
721
+ return {
722
+ text: row.returnPct == null ? "—" : formatPercentRaw(row.returnPct),
723
+ color: row.returnPct == null ? colors.textMuted : priceColor(row.returnPct),
724
+ };
725
+ case "bar":
726
+ return {
727
+ text: renderBar(row.weight, column.width),
728
+ color: colors.textMuted,
729
+ };
730
+ }
731
+ }}
732
+ />
733
+ </>
734
+ )}
735
+ </>
736
+ )}
737
+ </box>
738
+ );
739
+ }
740
+
741
+ export const analyticsPlugin: GloomPlugin = {
742
+ id: "analytics",
743
+ name: "Portfolio Analytics",
744
+ version: "1.0.0",
745
+ description: "Sharpe ratio, beta, and sector allocation for the active portfolio",
746
+ toggleable: true,
747
+
748
+ panes: [
749
+ {
750
+ id: "analytics",
751
+ name: "Portfolio Analytics",
752
+ icon: "R",
753
+ component: PortfolioAnalyticsPane,
754
+ defaultPosition: "right",
755
+ defaultMode: "floating",
756
+ defaultFloatingSize: { width: 80, height: 30 },
757
+ },
758
+ ],
759
+
760
+ paneTemplates: [
761
+ {
762
+ id: "analytics-pane",
763
+ paneId: "analytics",
764
+ label: "Portfolio Analytics",
765
+ description: "Sharpe ratio, beta vs S&P 500, and sector allocation for your portfolio.",
766
+ keywords: ["risk", "analytics", "sharpe", "beta", "sector", "allocation", "portfolio"],
767
+ shortcut: { prefix: "PORT" },
768
+ canCreate: (context) => context.config.portfolios.length > 0,
769
+ createInstance: (context) => {
770
+ const portfolioId = resolveTemplatePortfolioId(context.config.portfolios, context.activeCollectionId);
771
+ return portfolioId ? { params: { portfolioId } } : null;
772
+ },
773
+ },
774
+ ],
775
+ };