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.
- package/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -1,23 +1,38 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef
|
|
2
|
-
import {
|
|
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 {
|
|
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 =
|
|
45
|
-
const priceWidth =
|
|
46
|
-
const changeWidth =
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
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: "
|
|
57
|
-
{ id: "
|
|
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"
|
|
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 [
|
|
116
|
-
|
|
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
|
-
|
|
290
|
+
setRowsByCollection((prev) => updateRowsForCollection(prev, activeCollection.id, (rows) => (
|
|
291
|
+
rows.map((row) => ({ ...row, loading: false }))
|
|
292
|
+
)));
|
|
139
293
|
return;
|
|
140
294
|
}
|
|
141
295
|
|
|
142
|
-
|
|
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 =
|
|
325
|
+
const fetches = loadQuotes().then((quotesByEtf) => Promise.allSettled(sectorDefs.map(async (sector) => {
|
|
145
326
|
try {
|
|
146
|
-
const
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
362
|
+
fetches.then(() => {
|
|
172
363
|
if (fetchGenRef.current === gen) {
|
|
173
|
-
|
|
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:
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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: "
|
|
65
|
-
|
|
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
|
|
68
|
-
|
|
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:
|
|
71
|
-
binding: { kind: "fixed", symbol:
|
|
72
|
-
settings: {
|
|
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;
|