gloomberb 0.7.2 → 0.7.4

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 (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -1,23 +1,38 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { DataTableView, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
+ import { Box } from "../../../ui";
3
+ import { DataTableView, Tabs, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
3
4
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
5
+ import type { PricePoint, Quote } from "../../../types/financials";
4
6
  import { colors, priceColor } from "../../../theme/colors";
5
7
  import { formatCurrency, formatPercentRaw } from "../../../utils/format";
6
- import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
7
- import { SECTORS, type SectorDef } from "./sector-data";
8
+ import { useAssetData, useDebouncedPluginPaneState, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
9
+ import {
10
+ SECTOR_COLLECTIONS,
11
+ getSectorCollection,
12
+ type SectorCollectionId,
13
+ type SectorDef,
14
+ } from "./sector-data";
8
15
 
9
16
  const REFRESH_INTERVAL_MS = 60_000;
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ const ONE_MONTH_DAYS = 30;
19
+ const ONE_YEAR_DAYS = 365;
20
+ const DEFAULT_COLLECTION_ID: SectorCollectionId = "sectors";
10
21
 
11
22
  interface SectorRow extends SectorDef {
12
23
  price: number | null;
13
24
  changePercent: number | null;
25
+ return1M: number | null;
26
+ return1Y: number | null;
14
27
  currency: string;
15
28
  loading: boolean;
16
29
  }
17
30
 
18
- type SectorColumnId = "name" | "etf" | "price" | "changePercent" | "bar";
31
+ type SectorColumnId = "name" | "etf" | "price" | "changePercent" | "return1M" | "return1Y" | "bar";
19
32
  type SectorColumn = DataTableColumn & { id: SectorColumnId };
20
33
  type SortDirection = "asc" | "desc";
34
+ type SectorRowsByCollection = Record<SectorCollectionId, SectorRow[]>;
35
+ type SectorRefreshByCollection = Partial<Record<SectorCollectionId, number>>;
21
36
 
22
37
  interface SectorSortPreference {
23
38
  columnId: SectorColumnId;
@@ -40,22 +55,130 @@ function formatTime(date: Date): string {
40
55
  return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
41
56
  }
42
57
 
58
+ function createLoadingRows(sectors: readonly SectorDef[]): SectorRow[] {
59
+ return sectors.map((sector) => ({
60
+ ...sector,
61
+ price: null,
62
+ changePercent: null,
63
+ return1M: null,
64
+ return1Y: null,
65
+ currency: "USD",
66
+ loading: true,
67
+ }));
68
+ }
69
+
70
+ function createRowsByCollection(): SectorRowsByCollection {
71
+ return {
72
+ sectors: createLoadingRows(getSectorCollection("sectors").items),
73
+ industries: createLoadingRows(getSectorCollection("industries").items),
74
+ };
75
+ }
76
+
77
+ const INITIAL_ROWS_BY_COLLECTION = createRowsByCollection();
78
+ const INITIAL_REFRESH_BY_COLLECTION: SectorRefreshByCollection = {};
79
+
80
+ function normalizeRowsForCollection(
81
+ rowsByCollection: SectorRowsByCollection,
82
+ collectionId: SectorCollectionId,
83
+ ): SectorRow[] {
84
+ const collection = getSectorCollection(collectionId);
85
+ const rows = rowsByCollection[collectionId] ?? [];
86
+ return collection.items.map((sector) => {
87
+ const existing = rows.find((row) => row.etf === sector.etf);
88
+ return {
89
+ ...sector,
90
+ price: existing?.price ?? null,
91
+ changePercent: existing?.changePercent ?? null,
92
+ return1M: existing?.return1M ?? null,
93
+ return1Y: existing?.return1Y ?? null,
94
+ currency: existing?.currency ?? "USD",
95
+ loading: existing?.loading ?? true,
96
+ };
97
+ });
98
+ }
99
+
100
+ function updateRowsForCollection(
101
+ rowsByCollection: SectorRowsByCollection,
102
+ collectionId: SectorCollectionId,
103
+ updater: (rows: SectorRow[]) => SectorRow[],
104
+ ): SectorRowsByCollection {
105
+ return {
106
+ ...rowsByCollection,
107
+ [collectionId]: updater(normalizeRowsForCollection(rowsByCollection, collectionId)),
108
+ };
109
+ }
110
+
111
+ function getPricePointTimestamp(point: PricePoint): number {
112
+ const value = point.date as Date | string | number | null | undefined;
113
+ if (value instanceof Date) return value.getTime();
114
+ if (value == null) return Number.NaN;
115
+ return new Date(value).getTime();
116
+ }
117
+
118
+ function getSortedHistory(history: readonly PricePoint[]): Array<{ point: PricePoint; timestamp: number }> {
119
+ return history
120
+ .map((point) => ({ point, timestamp: getPricePointTimestamp(point) }))
121
+ .filter(({ point, timestamp }) => (
122
+ Number.isFinite(timestamp)
123
+ && Number.isFinite(point.close)
124
+ && point.close > 0
125
+ ))
126
+ .sort((left, right) => left.timestamp - right.timestamp);
127
+ }
128
+
129
+ function latestHistoryClose(history: readonly PricePoint[]): number | null {
130
+ return getSortedHistory(history).at(-1)?.point.close ?? null;
131
+ }
132
+
133
+ export function computeTrailingReturn(
134
+ history: readonly PricePoint[],
135
+ days: number,
136
+ latestPrice?: number | null,
137
+ ): number | null {
138
+ const points = getSortedHistory(history);
139
+ if (points.length < 2) return null;
140
+
141
+ const latest = points.at(-1)!;
142
+ const endPrice = latestPrice != null && Number.isFinite(latestPrice) && latestPrice > 0
143
+ ? latestPrice
144
+ : latest.point.close;
145
+ const targetTimestamp = latest.timestamp - days * DAY_MS;
146
+ let baseline = points[0]!;
147
+ for (const point of points) {
148
+ if (point.timestamp > targetTimestamp) break;
149
+ baseline = point;
150
+ }
151
+ const baselinePrice = baseline.point.close;
152
+ if (!Number.isFinite(endPrice) || !Number.isFinite(baselinePrice) || baselinePrice <= 0) return null;
153
+ return (endPrice / baselinePrice - 1) * 100;
154
+ }
155
+
43
156
  function buildColumns(width: number): SectorColumn[] {
44
- const etfWidth = 6;
45
- const priceWidth = 11;
46
- const changeWidth = 9;
47
- const barWidth = Math.max(4, Math.min(24, Math.floor(width * 0.24)));
48
- const columnCount = 5;
49
- const fixedWidth = etfWidth + priceWidth + changeWidth + barWidth;
50
- const nameWidth = Math.max(12, width - 2 - columnCount - fixedWidth);
51
-
52
- return [
157
+ const etfWidth = 4;
158
+ const priceWidth = 8;
159
+ const changeWidth = 8;
160
+ const returnWidth = 8;
161
+ const showBar = width >= 67;
162
+ const compactBar = width < 82;
163
+ const barWidth = showBar
164
+ ? compactBar ? 6 : Math.max(8, Math.min(18, Math.floor(width * 0.16)))
165
+ : 0;
166
+ const columnCount = showBar ? 7 : 6;
167
+ const fixedWidth = etfWidth + priceWidth + changeWidth + returnWidth * 2 + barWidth;
168
+ const nameWidth = Math.max(12, Math.min(22, width - 2 - columnCount - fixedWidth));
169
+
170
+ const columns: SectorColumn[] = [
53
171
  { id: "name", label: "SECTOR", width: nameWidth, align: "left" },
54
172
  { id: "etf", label: "ETF", width: etfWidth, align: "left" },
55
173
  { id: "price", label: "LAST", width: priceWidth, align: "right" },
56
- { id: "changePercent", label: "CHG%", width: changeWidth, align: "right" },
57
- { id: "bar", label: "MOVE", width: barWidth, align: "left" },
174
+ { id: "changePercent", label: "1D", width: changeWidth, align: "right" },
175
+ { id: "return1M", label: "1M", width: returnWidth, align: "right" },
176
+ { id: "return1Y", label: "1Y", width: returnWidth, align: "right" },
58
177
  ];
178
+ if (showBar) {
179
+ columns.push({ id: "bar", label: "MOVE", width: barWidth, align: "left" });
180
+ }
181
+ return columns;
59
182
  }
60
183
 
61
184
  function getSortValue(columnId: SectorColumnId, row: SectorRow): string | number | null {
@@ -67,6 +190,11 @@ function getSortValue(columnId: SectorColumnId, row: SectorRow): string | number
67
190
  case "price":
68
191
  return row.price;
69
192
  case "changePercent":
193
+ return row.changePercent;
194
+ case "return1M":
195
+ return row.return1M;
196
+ case "return1Y":
197
+ return row.return1Y;
70
198
  case "bar":
71
199
  return row.changePercent;
72
200
  }
@@ -100,7 +228,12 @@ function nextSortPreference(current: SectorSortPreference, columnId: string): Se
100
228
  if (current.columnId !== typedColumnId) {
101
229
  return {
102
230
  columnId: typedColumnId,
103
- direction: typedColumnId === "changePercent" || typedColumnId === "bar" ? "desc" : "asc",
231
+ direction: typedColumnId === "changePercent"
232
+ || typedColumnId === "return1M"
233
+ || typedColumnId === "return1Y"
234
+ || typedColumnId === "bar"
235
+ ? "desc"
236
+ : "asc",
104
237
  };
105
238
  }
106
239
  if (current.direction === "desc") {
@@ -112,20 +245,39 @@ function nextSortPreference(current: SectorSortPreference, columnId: string): Se
112
245
  function SectorPerformancePane({ focused, width, height }: PaneProps) {
113
246
  const dataProvider = useAssetData();
114
247
  const { navigateTicker } = usePluginTickerActions();
115
- const [rows, setRows] = useState<SectorRow[]>(
116
- SECTORS.map((sector) => ({ ...sector, price: null, changePercent: null, currency: "USD", loading: true })),
248
+ const [activeCollectionId, setActiveCollectionId] = usePluginPaneState<SectorCollectionId>(
249
+ "activeCollectionId",
250
+ DEFAULT_COLLECTION_ID,
251
+ );
252
+ const activeCollection = getSectorCollection(activeCollectionId);
253
+ const [rowsByCollection, setRowsByCollection] = useDebouncedPluginPaneState<SectorRowsByCollection>(
254
+ "rowsByCollection:v1",
255
+ INITIAL_ROWS_BY_COLLECTION,
256
+ );
257
+ const [lastRefreshByCollection, setLastRefreshByCollection] = useDebouncedPluginPaneState<SectorRefreshByCollection>(
258
+ "lastRefreshByCollection:v1",
259
+ INITIAL_REFRESH_BY_COLLECTION,
117
260
  );
118
261
  const [selectedEtf, setSelectedEtf] = usePluginPaneState<string | null>("selectedEtf", null);
119
262
  const [sortPreference, setSortPreference] = usePluginPaneState<SectorSortPreference>(
120
263
  "sortPreference",
121
264
  DEFAULT_SORT_PREFERENCE,
122
265
  );
123
- const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
124
266
 
125
267
  const fetchGenRef = useRef(0);
126
268
 
127
269
  const columns = useMemo(() => buildColumns(width), [width]);
270
+ const rows = useMemo(
271
+ () => normalizeRowsForCollection(rowsByCollection, activeCollection.id),
272
+ [activeCollection.id, rowsByCollection],
273
+ );
128
274
  const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
275
+ const lastRefreshMs = lastRefreshByCollection[activeCollection.id];
276
+ const lastRefreshText = lastRefreshMs ? formatTime(new Date(lastRefreshMs)) : "loading";
277
+ const tabs = useMemo(() => SECTOR_COLLECTIONS.map((collection) => ({
278
+ label: collection.label,
279
+ value: collection.id,
280
+ })), []);
129
281
  const selectedIdx = selectedEtf
130
282
  ? sortedRows.findIndex((row) => row.etf === selectedEtf)
131
283
  : -1;
@@ -135,45 +287,87 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
135
287
  fetchGenRef.current += 1;
136
288
  const gen = fetchGenRef.current;
137
289
  if (!dataProvider) {
138
- setRows((prev) => prev.map((row) => ({ ...row, loading: false })));
290
+ setRowsByCollection((prev) => updateRowsForCollection(prev, activeCollection.id, (rows) => (
291
+ rows.map((row) => ({ ...row, loading: false }))
292
+ )));
139
293
  return;
140
294
  }
141
295
 
142
- setRows((prev) => prev.map((row) => ({ ...row, loading: true })));
296
+ const sectorDefs = activeCollection.items;
297
+ const collectionId = activeCollection.id;
298
+
299
+ setRowsByCollection((prev) => updateRowsForCollection(prev, collectionId, (rows) => (
300
+ rows.map((row) => ({ ...row, loading: true }))
301
+ )));
302
+
303
+ const loadQuotes = async (): Promise<Map<string, Quote | null>> => {
304
+ const quotes = new Map<string, Quote | null>();
305
+ if (dataProvider.getQuotesBatch) {
306
+ const results = await dataProvider.getQuotesBatch(
307
+ sectorDefs.map((sector) => ({ symbol: sector.etf, exchange: "" })),
308
+ ).catch(() => []);
309
+ for (const result of results) {
310
+ quotes.set(result.target.symbol, result.quote ?? null);
311
+ }
312
+ return quotes;
313
+ }
314
+
315
+ await Promise.all(sectorDefs.map(async (sector) => {
316
+ try {
317
+ quotes.set(sector.etf, await dataProvider.getQuote(sector.etf, ""));
318
+ } catch {
319
+ quotes.set(sector.etf, null);
320
+ }
321
+ }));
322
+ return quotes;
323
+ };
143
324
 
144
- const fetches = SECTORS.map(async (sector) => {
325
+ const fetches = loadQuotes().then((quotesByEtf) => Promise.allSettled(sectorDefs.map(async (sector) => {
145
326
  try {
146
- const quote = await dataProvider.getQuote(sector.etf, "");
327
+ const historyResult = await dataProvider.getPriceHistory(sector.etf, "", "1Y")
328
+ .then((value) => ({ status: "fulfilled" as const, value }))
329
+ .catch(() => ({ status: "rejected" as const }));
147
330
  if (fetchGenRef.current !== gen) return;
148
- setRows((prev) =>
149
- prev.map((row) =>
331
+ const quote = quotesByEtf.get(sector.etf) ?? null;
332
+ const history = historyResult.status === "fulfilled" ? historyResult.value : [];
333
+ const historyClose = latestHistoryClose(history);
334
+ const price = quote?.price ?? historyClose;
335
+ const return1M = computeTrailingReturn(history, ONE_MONTH_DAYS, price);
336
+ const return1Y = computeTrailingReturn(history, ONE_YEAR_DAYS, price);
337
+ setRowsByCollection((prev) => updateRowsForCollection(prev, collectionId, (rows) => (
338
+ rows.map((row) =>
150
339
  row.etf === sector.etf
151
340
  ? {
152
341
  ...row,
153
- price: quote?.price ?? null,
342
+ price,
154
343
  changePercent: quote?.changePercent ?? null,
344
+ return1M,
345
+ return1Y,
155
346
  currency: quote?.currency ?? "USD",
156
347
  loading: false,
157
348
  }
158
349
  : row,
159
- ),
160
- );
350
+ )
351
+ )));
161
352
  } catch {
162
353
  if (fetchGenRef.current !== gen) return;
163
- setRows((prev) =>
164
- prev.map((row) =>
354
+ setRowsByCollection((prev) => updateRowsForCollection(prev, collectionId, (rows) => (
355
+ rows.map((row) =>
165
356
  row.etf === sector.etf ? { ...row, loading: false } : row,
166
- ),
167
- );
357
+ )
358
+ )));
168
359
  }
169
- });
360
+ })));
170
361
 
171
- Promise.allSettled(fetches).then(() => {
362
+ fetches.then(() => {
172
363
  if (fetchGenRef.current === gen) {
173
- setLastRefresh(new Date());
364
+ setLastRefreshByCollection((prev) => ({
365
+ ...prev,
366
+ [collectionId]: Date.now(),
367
+ }));
174
368
  }
175
369
  });
176
- }, [dataProvider]);
370
+ }, [activeCollection, dataProvider, setLastRefreshByCollection, setRowsByCollection]);
177
371
 
178
372
  useEffect(() => {
179
373
  fetchAll();
@@ -181,6 +375,11 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
181
375
  return () => clearInterval(interval);
182
376
  }, [fetchAll]);
183
377
 
378
+ useEffect(() => {
379
+ if (activeCollectionId === activeCollection.id) return;
380
+ setActiveCollectionId(activeCollection.id);
381
+ }, [activeCollection.id, activeCollectionId, setActiveCollectionId]);
382
+
184
383
  useEffect(() => {
185
384
  if (selectedEtf && sortedRows.some((row) => row.etf === selectedEtf)) return;
186
385
  const firstRow = sortedRows[0];
@@ -244,6 +443,16 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
244
443
  text: row.changePercent !== null ? formatPercentRaw(row.changePercent) : "—",
245
444
  color: selectedColor ?? (row.changePercent !== null ? priceColor(row.changePercent) : colors.textDim),
246
445
  };
446
+ case "return1M":
447
+ return {
448
+ text: row.loading && row.return1M === null ? "…" : row.return1M !== null ? formatPercentRaw(row.return1M) : "—",
449
+ color: selectedColor ?? (row.return1M !== null ? priceColor(row.return1M) : colors.textDim),
450
+ };
451
+ case "return1Y":
452
+ return {
453
+ text: row.loading && row.return1Y === null ? "…" : row.return1Y !== null ? formatPercentRaw(row.return1Y) : "—",
454
+ color: selectedColor ?? (row.return1Y !== null ? priceColor(row.return1Y) : colors.textDim),
455
+ };
247
456
  case "bar": {
248
457
  const bar = row.changePercent !== null ? buildBar(row.changePercent, column.width) : "";
249
458
  const barColor = row.changePercent !== null && row.changePercent >= 0 ? colors.positive : colors.negative;
@@ -257,13 +466,34 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
257
466
 
258
467
  usePaneFooter("sectors", () => ({
259
468
  info: [
469
+ {
470
+ id: "collection",
471
+ parts: [{ text: activeCollection.label, tone: "label" }],
472
+ },
260
473
  {
261
474
  id: "updated",
262
- parts: [{ text: lastRefresh ? formatTime(lastRefresh) : "loading", tone: lastRefresh ? "value" : "muted" }],
475
+ parts: [{ text: lastRefreshText, tone: lastRefreshMs ? "value" : "muted" }],
263
476
  },
264
477
  ],
265
478
  hints: [{ id: "refresh", key: "r", label: "efresh", onPress: fetchAll }],
266
- }), [fetchAll, lastRefresh]);
479
+ }), [activeCollection.label, fetchAll, lastRefreshMs, lastRefreshText]);
480
+
481
+ const rootBefore = (
482
+ <Box height={1} paddingX={1}>
483
+ <Tabs
484
+ tabs={tabs}
485
+ activeValue={activeCollection.id}
486
+ onSelect={(value) => {
487
+ const nextId = value as SectorCollectionId;
488
+ setActiveCollectionId(nextId);
489
+ setSelectedEtf(null);
490
+ }}
491
+ compact
492
+ variant="bare"
493
+ focused={focused}
494
+ />
495
+ </Box>
496
+ );
267
497
 
268
498
  return (
269
499
  <DataTableView<SectorRow, SectorColumn>
@@ -272,8 +502,10 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
272
502
  onSelectIndex={selectIndex}
273
503
  onActivateIndex={(_index, row) => openRow(row)}
274
504
  onRootKeyDown={handleTableKeyDown}
505
+ rootBefore={rootBefore}
275
506
  rootWidth={width}
276
507
  rootHeight={height}
508
+ resetScrollKey={activeCollection.id}
277
509
  columns={columns}
278
510
  items={sortedRows}
279
511
  sortColumnId={sortPreference.columnId}
@@ -293,7 +525,7 @@ export const sectorsPlugin: GloomPlugin = {
293
525
  id: "sectors",
294
526
  name: "Sector Performance",
295
527
  version: "1.0.0",
296
- description: "S&P 500 sector performance via sector ETF proxies",
528
+ description: "S&P 500 sector and industry performance via ETF proxies",
297
529
  toggleable: true,
298
530
 
299
531
  panes: [
@@ -304,7 +536,7 @@ export const sectorsPlugin: GloomPlugin = {
304
536
  component: SectorPerformancePane,
305
537
  defaultPosition: "right",
306
538
  defaultMode: "floating",
307
- defaultFloatingSize: { width: 75, height: 16 },
539
+ defaultFloatingSize: { width: 82, height: 18 },
308
540
  },
309
541
  ],
310
542
 
@@ -313,8 +545,8 @@ export const sectorsPlugin: GloomPlugin = {
313
545
  id: "sectors-pane",
314
546
  paneId: "sectors",
315
547
  label: "Sector Performance",
316
- description: "S&P 500 sector performance sorted by daily change.",
317
- keywords: ["sector", "sectors", "etf", "xlk", "xlv", "xlf", "performance", "spdr"],
548
+ description: "S&P 500 sector and industry performance sorted by daily change.",
549
+ keywords: ["sector", "sectors", "industry", "semis", "defense", "food", "leisure", "etf", "xlk", "xlv", "xlf", "performance", "spdr"],
318
550
  shortcut: { prefix: "BI" },
319
551
  },
320
552
  ],
@@ -4,7 +4,15 @@ export interface SectorDef {
4
4
  etf: string;
5
5
  }
6
6
 
7
- export const SECTORS: SectorDef[] = [
7
+ export type SectorCollectionId = "sectors" | "industries";
8
+
9
+ export interface SectorCollection {
10
+ id: SectorCollectionId;
11
+ label: string;
12
+ items: SectorDef[];
13
+ }
14
+
15
+ const CORE_SECTORS: SectorDef[] = [
8
16
  { name: "Technology", etf: "XLK" },
9
17
  { name: "Healthcare", etf: "XLV" },
10
18
  { name: "Financials", etf: "XLF" },
@@ -17,3 +25,35 @@ export const SECTORS: SectorDef[] = [
17
25
  { name: "Real Estate", etf: "XLRE" },
18
26
  { name: "Materials", etf: "XLB" },
19
27
  ];
28
+
29
+ const INDUSTRIES: SectorDef[] = [
30
+ { name: "Semiconductors", etf: "SMH" },
31
+ { name: "Software", etf: "IGV" },
32
+ { name: "Cybersecurity", etf: "CIBR" },
33
+ { name: "Aero/Defense", etf: "ITA" },
34
+ { name: "Banks", etf: "KBE" },
35
+ { name: "Regional Banks", etf: "KRE" },
36
+ { name: "Biotech", etf: "XBI" },
37
+ { name: "Pharma", etf: "PPH" },
38
+ { name: "Homebuilders", etf: "XHB" },
39
+ { name: "Retail", etf: "XRT" },
40
+ { name: "Food & Bev.", etf: "PBJ" },
41
+ { name: "Leisure", etf: "PEJ" },
42
+ { name: "Transportation", etf: "IYT" },
43
+ { name: "Metals/Mining", etf: "XME" },
44
+ { name: "Oil & Gas E&P", etf: "XOP" },
45
+ { name: "Clean Energy", etf: "ICLN" },
46
+ { name: "Infrastructure", etf: "PAVE" },
47
+ { name: "Gold Miners", etf: "GDX" },
48
+ ];
49
+
50
+ export const SECTOR_COLLECTIONS: SectorCollection[] = [
51
+ { id: "sectors", label: "Sectors", items: CORE_SECTORS },
52
+ { id: "industries", label: "Industries", items: INDUSTRIES },
53
+ ];
54
+
55
+ export const SECTORS = CORE_SECTORS;
56
+
57
+ export function getSectorCollection(id: SectorCollectionId): SectorCollection {
58
+ return SECTOR_COLLECTIONS.find((collection) => collection.id === id) ?? SECTOR_COLLECTIONS[0]!;
59
+ }
@@ -1,4 +1,4 @@
1
- import { Box } from "../../../ui";
1
+ import { Box, useUiCapabilities } from "../../../ui";
2
2
  import { useViewport } from "../../../react/input";
3
3
  import { useCallback, useMemo } from "react";
4
4
  import { ChartIndicatorSelector } from "../../../components/chart/chart-indicator-selector";
@@ -39,11 +39,12 @@ export function ChartTab({
39
39
  financials: TickerFinancials | null;
40
40
  }) {
41
41
  const { width: termWidth, height: termHeight } = useViewport();
42
+ const { fractionalViewport = false } = useUiCapabilities();
42
43
  const [rawIndicatorSelection] = usePluginConfigState<unknown>(CHART_INDICATORS_PLUGIN_CONFIG_KEY, null);
43
44
  const [indicatorSelectionVersion] = usePluginConfigState<unknown>(CHART_INDICATORS_PLUGIN_CONFIG_VERSION_KEY, null);
44
45
  const setPluginConfigStates = useSetPluginConfigStates();
45
46
 
46
- const chartWidth = Math.max((width || Math.floor(termWidth * 0.55)) - 2, 30);
47
+ const chartWidth = Math.max((width || Math.floor(termWidth * 0.55)) - (fractionalViewport ? 1 : 2), 30);
47
48
  const chartHeight = Math.max(height ?? termHeight - 8, 10);
48
49
  const hasStoredIndicatorSelection = Array.isArray(rawIndicatorSelection);
49
50
  const selectedIndicatorIds = useMemo(
@@ -4,9 +4,11 @@ import { TickerDetailPane } from "./pane";
4
4
  import { QuoteMonitorPane } from "./quote-monitor";
5
5
  import {
6
6
  buildQuoteMonitorSettingsDef,
7
+ buildQuoteMonitorPaneTitle,
7
8
  buildTickerDetailSettingsDef,
8
9
  getTickerDetailPaneSettings,
9
10
  } from "./settings";
11
+ import { formatTickerListInput } from "../../../utils/ticker-list";
10
12
 
11
13
  export { FinancialsTab } from "./financials-tab";
12
14
  export { QuoteMonitorPane } from "./quote-monitor";
@@ -34,7 +36,7 @@ export const tickerDetailPlugin: GloomPlugin = {
34
36
  component: QuoteMonitorPane,
35
37
  defaultPosition: "right",
36
38
  defaultMode: "floating",
37
- defaultFloatingSize: { width: 64, height: 8 },
39
+ defaultFloatingSize: { width: 72, height: 10 },
38
40
  settings: buildQuoteMonitorSettingsDef(),
39
41
  },
40
42
  ],
@@ -59,17 +61,36 @@ export const tickerDetailPlugin: GloomPlugin = {
59
61
  id: "quote-monitor-pane",
60
62
  paneId: "quote-monitor",
61
63
  label: "Quote Monitor",
62
- description: "Open a compact quote monitor for the selected ticker",
64
+ description: "Open a compact quote monitor for one or more tickers",
63
65
  keywords: ["quote", "monitor", "price", "ticker", "pane"],
64
- shortcut: { prefix: "QQ", argPlaceholder: "ticker", argKind: "ticker" },
65
- canCreate: (context, options) => (options?.symbol ?? normalizeTickerInput(context.activeTicker, options?.arg)) !== null,
66
+ shortcut: { prefix: "QQ", argPlaceholder: "tickers", argKind: "ticker-list" },
67
+ wizard: [
68
+ {
69
+ key: "tickers",
70
+ label: "Quote Tickers",
71
+ placeholder: "AAPL, MSFT, NVDA",
72
+ body: ["Enter one or more ticker symbols separated by commas."],
73
+ type: "text",
74
+ },
75
+ ],
76
+ canCreate: (context, options) => (
77
+ (options?.symbols?.length ?? 0) > 0
78
+ || normalizeTickerInput(context.activeTicker, options?.arg) !== null
79
+ ),
66
80
  createInstance: (context, options) => {
67
- const ticker = options?.symbol ?? normalizeTickerInput(context.activeTicker, options?.arg);
68
- return ticker
81
+ const symbols = options?.symbols?.length
82
+ ? options.symbols
83
+ : [normalizeTickerInput(context.activeTicker, options?.arg)].filter((symbol): symbol is string => !!symbol);
84
+ const primarySymbol = symbols[0];
85
+ return primarySymbol
69
86
  ? {
70
- title: ticker,
71
- binding: { kind: "fixed", symbol: ticker },
72
- settings: { symbol: ticker },
87
+ title: buildQuoteMonitorPaneTitle(symbols),
88
+ binding: { kind: "fixed", symbol: primarySymbol },
89
+ settings: {
90
+ symbol: primarySymbol,
91
+ symbols,
92
+ symbolsText: formatTickerListInput(symbols),
93
+ },
73
94
  placement: "floating",
74
95
  }
75
96
  : null;