gloomberb 0.9.6 → 0.9.8
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/package.json +1 -1
- package/src/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/config.ts +1 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -5,6 +5,7 @@ import { AppContext, appReducer, createInitialState, PaneInstanceProvider } from
|
|
|
5
5
|
import { createConfigBackedTestPluginRuntime, createTestPluginRuntime } from "../../../test-support/plugin-runtime";
|
|
6
6
|
import { createDefaultConfig, findPaneInstance, type PaneInstanceConfig } from "../../../types/config";
|
|
7
7
|
import { TextAttributes } from "../../../ui";
|
|
8
|
+
import { apiClient } from "../../../api-client";
|
|
8
9
|
import { PluginRenderProvider } from "../../runtime";
|
|
9
10
|
import { gloomberbCloudPlugin } from "../cloud";
|
|
10
11
|
import { ChatContent } from "../chat";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
installChatApiTestDefaults,
|
|
19
20
|
installServerChannels,
|
|
20
21
|
lineText,
|
|
22
|
+
makeMessage,
|
|
21
23
|
MemoryPersistence,
|
|
22
24
|
type ChatTestSetup,
|
|
23
25
|
} from "./test-harness";
|
|
@@ -214,6 +216,72 @@ describe("ChatContent channel sidebar", () => {
|
|
|
214
216
|
expect(setup().captureCharFrame()).not.toContain("#options");
|
|
215
217
|
});
|
|
216
218
|
|
|
219
|
+
test("opens a new direct-message dialog from the DMs header", async () => {
|
|
220
|
+
const controller = createController({
|
|
221
|
+
messages: [{
|
|
222
|
+
...makeMessage(1),
|
|
223
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
224
|
+
}],
|
|
225
|
+
sessionToken: "token-123",
|
|
226
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
227
|
+
});
|
|
228
|
+
installServerChannels(controller);
|
|
229
|
+
controller.refreshChannels = async () => {};
|
|
230
|
+
controller.refreshChannelMessages = async () => {};
|
|
231
|
+
const openedTargets: Array<{ username?: string }> = [];
|
|
232
|
+
const originalOpenDirectChannel = apiClient.openDirectChannel.bind(apiClient);
|
|
233
|
+
apiClient.openDirectChannel = async (target) => {
|
|
234
|
+
openedTargets.push(target);
|
|
235
|
+
return {
|
|
236
|
+
id: "dm:bob",
|
|
237
|
+
name: "@bob",
|
|
238
|
+
kind: "direct",
|
|
239
|
+
created_at: "2026-07-03T09:30:00.000Z",
|
|
240
|
+
dmUser: { id: "u2", username: "bob", displayName: "Bob" },
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
const ChannelPane = createChannelPane(controller, "everyone");
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await act(async () => {
|
|
247
|
+
testSetup = await testRender(<ChannelPane />, {
|
|
248
|
+
width: 90,
|
|
249
|
+
height: 14,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await flushFrame();
|
|
254
|
+
const lines = setup().captureCharFrame().split("\n");
|
|
255
|
+
const row = lines.findIndex((line) => line.includes("DMs"));
|
|
256
|
+
const col = lines[row]?.lastIndexOf("+") ?? -1;
|
|
257
|
+
expect(row).toBeGreaterThanOrEqual(0);
|
|
258
|
+
expect(col).toBeGreaterThanOrEqual(0);
|
|
259
|
+
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await setup().mockMouse.click(col, row);
|
|
262
|
+
await setup().renderOnce();
|
|
263
|
+
await setup().renderOnce();
|
|
264
|
+
});
|
|
265
|
+
await flushFrame();
|
|
266
|
+
|
|
267
|
+
expect(setup().captureCharFrame()).toContain("New DM");
|
|
268
|
+
|
|
269
|
+
await act(async () => {
|
|
270
|
+
await setup().mockInput.typeText("@bob");
|
|
271
|
+
setup().mockInput.pressEnter();
|
|
272
|
+
await setup().renderOnce();
|
|
273
|
+
await setup().renderOnce();
|
|
274
|
+
});
|
|
275
|
+
await flushFrame();
|
|
276
|
+
|
|
277
|
+
expect(openedTargets).toEqual([{ username: "bob" }]);
|
|
278
|
+
expect(setup().captureCharFrame()).toContain("@bob");
|
|
279
|
+
expect(setup().captureCharFrame()).not.toContain("New DM");
|
|
280
|
+
} finally {
|
|
281
|
+
apiClient.openDirectChannel = originalOpenDirectChannel;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
217
285
|
test("shows the sidebar online count footer", async () => {
|
|
218
286
|
const controller = createController({ sessionToken: "token-123" });
|
|
219
287
|
installServerChannels(controller);
|
|
@@ -314,8 +382,6 @@ describe("ChatContent channel sidebar", () => {
|
|
|
314
382
|
channelIdRef,
|
|
315
383
|
channels,
|
|
316
384
|
channelsLoading: false,
|
|
317
|
-
closeProfilePopover: () => {},
|
|
318
|
-
controller,
|
|
319
385
|
focused: true,
|
|
320
386
|
inputFocused: false,
|
|
321
387
|
onChannelChange: handleChannelChange,
|
|
@@ -412,6 +478,7 @@ describe("ChatContent channel sidebar", () => {
|
|
|
412
478
|
},
|
|
413
479
|
registerPaneTemplate: () => {},
|
|
414
480
|
registerTickerResearchTab: () => {},
|
|
481
|
+
registerSyncTransport: () => {},
|
|
415
482
|
registerShortcut: () => {},
|
|
416
483
|
registerCommand: () => {},
|
|
417
484
|
showPane: () => {},
|
|
@@ -109,9 +109,11 @@ export function ChannelSidebar({
|
|
|
109
109
|
keyboardFocused,
|
|
110
110
|
loading,
|
|
111
111
|
canManageNotifications,
|
|
112
|
+
canCreateConversation,
|
|
112
113
|
directExpanded,
|
|
113
114
|
onSelect,
|
|
114
115
|
onFocusRequest,
|
|
116
|
+
onCreateConversation,
|
|
115
117
|
onToggleNotifications,
|
|
116
118
|
onToggleDirectExpanded,
|
|
117
119
|
}: {
|
|
@@ -125,9 +127,11 @@ export function ChannelSidebar({
|
|
|
125
127
|
keyboardFocused: boolean;
|
|
126
128
|
loading: boolean;
|
|
127
129
|
canManageNotifications: boolean;
|
|
130
|
+
canCreateConversation: boolean;
|
|
128
131
|
directExpanded: boolean;
|
|
129
132
|
onSelect?: (channelId: string) => void;
|
|
130
133
|
onFocusRequest?: () => void;
|
|
134
|
+
onCreateConversation?: () => void;
|
|
131
135
|
onToggleNotifications?: (channelId: string, enabled: boolean) => void;
|
|
132
136
|
onToggleDirectExpanded?: () => void;
|
|
133
137
|
}) {
|
|
@@ -149,9 +153,9 @@ export function ChannelSidebar({
|
|
|
149
153
|
const conversationUnread = conversationChannels.some((channel) => (channelStateById.get(channel.id)?.unreadCount ?? 0) > 0);
|
|
150
154
|
const sidebarRows = useMemo(() => [
|
|
151
155
|
...publicChannels.map((channel) => ({ kind: "channel" as const, channel })),
|
|
152
|
-
...(conversationChannels.length > 0 ? [{ kind: "direct-header" as const }] : []),
|
|
156
|
+
...(conversationChannels.length > 0 || canCreateConversation ? [{ kind: "direct-header" as const }] : []),
|
|
153
157
|
...(directExpanded ? conversationChannels.map((channel) => ({ kind: "channel" as const, channel })) : []),
|
|
154
|
-
], [conversationChannels, directExpanded, publicChannels]);
|
|
158
|
+
], [canCreateConversation, conversationChannels, directExpanded, publicChannels]);
|
|
155
159
|
const sidebarLayoutHeight = nativePaneChrome ? "100%" : height;
|
|
156
160
|
const nativeFillStyle = nativePaneChrome ? { minHeight: 0 } : undefined;
|
|
157
161
|
const sidebarBorder = borderWidth > 0
|
|
@@ -201,6 +205,26 @@ export function ChannelSidebar({
|
|
|
201
205
|
>
|
|
202
206
|
{` ${directExpanded ? "▾" : "▸"} DMs`}
|
|
203
207
|
</Text>
|
|
208
|
+
<Box flexGrow={1} />
|
|
209
|
+
{canCreateConversation ? (
|
|
210
|
+
<Box
|
|
211
|
+
width={3}
|
|
212
|
+
height={1}
|
|
213
|
+
alignItems="center"
|
|
214
|
+
justifyContent="center"
|
|
215
|
+
backgroundColor={hoveredChannelId === "direct-header:create" ? hoverBg() : sidebarBg}
|
|
216
|
+
onMouseOver={() => setHoveredChannelId("direct-header:create")}
|
|
217
|
+
onMouseOut={() => setHoveredChannelId((current) => (current === "direct-header:create" ? null : current))}
|
|
218
|
+
onMouseDown={(event: any) => {
|
|
219
|
+
event?.preventDefault?.();
|
|
220
|
+
event?.stopPropagation?.();
|
|
221
|
+
onCreateConversation?.();
|
|
222
|
+
}}
|
|
223
|
+
style={{ cursor: "pointer" }}
|
|
224
|
+
>
|
|
225
|
+
<Text fg={hoveredChannelId === "direct-header:create" ? colors.textMuted : colors.textDim}>+</Text>
|
|
226
|
+
</Box>
|
|
227
|
+
) : null}
|
|
204
228
|
</Box>
|
|
205
229
|
);
|
|
206
230
|
}
|
|
@@ -178,23 +178,33 @@ export function createChatTestControls(getSetup: () => ChatTestSetup) {
|
|
|
178
178
|
sequence?: string;
|
|
179
179
|
ctrl?: boolean;
|
|
180
180
|
meta?: boolean;
|
|
181
|
+
super?: boolean;
|
|
182
|
+
alt?: boolean;
|
|
181
183
|
shift?: boolean;
|
|
182
184
|
option?: boolean;
|
|
183
185
|
}) {
|
|
186
|
+
const keyEvent = {
|
|
187
|
+
ctrl: false,
|
|
188
|
+
meta: false,
|
|
189
|
+
option: false,
|
|
190
|
+
shift: false,
|
|
191
|
+
eventType: "press",
|
|
192
|
+
repeated: false,
|
|
193
|
+
defaultPrevented: false,
|
|
194
|
+
propagationStopped: false,
|
|
195
|
+
preventDefault() {
|
|
196
|
+
keyEvent.defaultPrevented = true;
|
|
197
|
+
},
|
|
198
|
+
stopPropagation() {
|
|
199
|
+
keyEvent.propagationStopped = true;
|
|
200
|
+
},
|
|
201
|
+
...event,
|
|
202
|
+
};
|
|
184
203
|
await act(async () => {
|
|
185
|
-
getSetup().renderer.keyInput.emit("keypress",
|
|
186
|
-
ctrl: false,
|
|
187
|
-
meta: false,
|
|
188
|
-
option: false,
|
|
189
|
-
shift: false,
|
|
190
|
-
eventType: "press",
|
|
191
|
-
repeated: false,
|
|
192
|
-
stopPropagation: () => {},
|
|
193
|
-
preventDefault: () => {},
|
|
194
|
-
...event,
|
|
195
|
-
} as any);
|
|
204
|
+
getSetup().renderer.keyInput.emit("keypress", keyEvent as any);
|
|
196
205
|
await getSetup().renderOnce();
|
|
197
206
|
});
|
|
207
|
+
return keyEvent;
|
|
198
208
|
},
|
|
199
209
|
};
|
|
200
210
|
}
|
|
@@ -103,6 +103,13 @@ export function createGloomberbCloudPlugin({
|
|
|
103
103
|
chatController.attachPersistence(ctx.persistence, ctx.resume);
|
|
104
104
|
chatController.setNotifier(ctx.notify);
|
|
105
105
|
|
|
106
|
+
ctx.registerSyncTransport({
|
|
107
|
+
id: "gloomberb-cloud",
|
|
108
|
+
isAvailable: () => apiClient.isVerified(),
|
|
109
|
+
pullSnapshot: () => apiClient.getSyncSnapshot(),
|
|
110
|
+
pushSnapshot: (snapshot, options) => apiClient.putSyncSnapshot(snapshot, options),
|
|
111
|
+
});
|
|
112
|
+
|
|
106
113
|
ctx.registerPane({
|
|
107
114
|
id: "chat",
|
|
108
115
|
name: "Chat",
|
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
} from "./settings";
|
|
19
19
|
import { resolveCorrelationHeatmapCellColors } from "./colors";
|
|
20
20
|
import {
|
|
21
|
+
buildRelationshipGraphSettingsDef,
|
|
21
22
|
createRelationshipPaneTemplate,
|
|
23
|
+
RELATIONSHIP_GRAPH_PANE_ID,
|
|
22
24
|
RelationshipGraphPane,
|
|
23
25
|
} from "./relationship/pane";
|
|
24
26
|
import {
|
|
@@ -234,13 +236,14 @@ export const correlationPlugin: GloomPlugin = {
|
|
|
234
236
|
settings: buildCorrelationSettingsDef(),
|
|
235
237
|
},
|
|
236
238
|
{
|
|
237
|
-
id:
|
|
239
|
+
id: RELATIONSHIP_GRAPH_PANE_ID,
|
|
238
240
|
name: "Relationship Graph",
|
|
239
241
|
icon: "R",
|
|
240
242
|
component: RelationshipGraphPane,
|
|
241
243
|
defaultPosition: "right",
|
|
242
244
|
defaultMode: "floating",
|
|
243
245
|
defaultFloatingSize: { width: 100, height: 30 },
|
|
246
|
+
settings: buildRelationshipGraphSettingsDef(),
|
|
244
247
|
},
|
|
245
248
|
],
|
|
246
249
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "../../../../ui";
|
|
2
2
|
import { colors } from "../../../../theme/colors";
|
|
3
|
+
import { Checkbox } from "../../../../components/ui/checkbox";
|
|
3
4
|
|
|
4
5
|
export function RelationshipMetricsTable({
|
|
5
6
|
rows,
|
|
@@ -39,7 +40,7 @@ export function RelationshipToggle({
|
|
|
39
40
|
}) {
|
|
40
41
|
return (
|
|
41
42
|
<Box
|
|
42
|
-
width={label.length +
|
|
43
|
+
width={label.length + 6}
|
|
43
44
|
height={1}
|
|
44
45
|
onMouseDown={(event: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
45
46
|
event.preventDefault?.();
|
|
@@ -47,7 +48,12 @@ export function RelationshipToggle({
|
|
|
47
48
|
onPress();
|
|
48
49
|
}}
|
|
49
50
|
>
|
|
50
|
-
<
|
|
51
|
+
<Checkbox
|
|
52
|
+
label={label}
|
|
53
|
+
checked={checked}
|
|
54
|
+
width={label.length + 6}
|
|
55
|
+
onChange={onPress}
|
|
56
|
+
/>
|
|
51
57
|
</Box>
|
|
52
58
|
);
|
|
53
59
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ProjectedChartPoint } from "../../../../components/chart/core/data";
|
|
2
2
|
import type { TimeRange } from "../../../../components/chart/core/types";
|
|
3
3
|
import type { ScatterChartPoint } from "../../../../components/chart/scatter-chart-renderer";
|
|
4
|
-
import type { PaneTemplateCreateOptions } from "../../../../types/plugin";
|
|
4
|
+
import type { PaneSettingsDef, PaneTemplateCreateOptions } from "../../../../types/plugin";
|
|
5
5
|
import type { PricePoint } from "../../../../types/financials";
|
|
6
|
-
import { parseTickerListInput } from "../../../../tickers/list";
|
|
6
|
+
import { formatTickerListInput, parseTickerListInput } from "../../../../tickers/list";
|
|
7
7
|
|
|
8
8
|
export type RelationshipRange = Extract<TimeRange, "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL">;
|
|
9
9
|
|
|
10
|
+
export const RELATIONSHIP_GRAPH_PANE_ID = "relationship-graph";
|
|
10
11
|
const RELATIONSHIP_RANGES: RelationshipRange[] = ["1M", "3M", "6M", "1Y", "5Y", "ALL"];
|
|
11
12
|
export const DEFAULT_RELATIONSHIP_SECOND_SYMBOL = "SPY";
|
|
12
13
|
const RELATIONSHIP_CORRELATION_WINDOWS = [30, 60, 120, 252] as const;
|
|
@@ -277,3 +278,22 @@ export function relationshipTemplateSymbols(
|
|
|
277
278
|
return null;
|
|
278
279
|
}
|
|
279
280
|
}
|
|
281
|
+
|
|
282
|
+
export function buildRelationshipGraphPaneTitle(pair: readonly [string, string]): string {
|
|
283
|
+
return `GR ${pair[0]}/${pair[1]}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function buildRelationshipGraphSettingsDef(): PaneSettingsDef {
|
|
287
|
+
return {
|
|
288
|
+
title: "Relationship Graph Settings",
|
|
289
|
+
fields: [
|
|
290
|
+
{
|
|
291
|
+
key: "symbolsText",
|
|
292
|
+
label: "Tickers",
|
|
293
|
+
description: `Enter one or two tickers. One ticker compares against ${DEFAULT_RELATIONSHIP_SECOND_SYMBOL}.`,
|
|
294
|
+
type: "text",
|
|
295
|
+
placeholder: formatTickerListInput(["AMD", "NVDA"]),
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -17,7 +17,9 @@ import { useRelationshipHistories } from "./history";
|
|
|
17
17
|
import {
|
|
18
18
|
DEFAULT_RELATIONSHIP_CORRELATION_WINDOW,
|
|
19
19
|
DEFAULT_RELATIONSHIP_SECOND_SYMBOL,
|
|
20
|
+
RELATIONSHIP_GRAPH_PANE_ID,
|
|
20
21
|
buildRelationshipAnalysis,
|
|
22
|
+
buildRelationshipGraphPaneTitle,
|
|
21
23
|
nextRelationshipRange,
|
|
22
24
|
nextRelationshipWindow,
|
|
23
25
|
relationshipSymbolsFromPaneSettings,
|
|
@@ -35,7 +37,11 @@ import {
|
|
|
35
37
|
formatNullableNumber,
|
|
36
38
|
} from "./view-model";
|
|
37
39
|
|
|
38
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
buildRelationshipAnalysis,
|
|
42
|
+
buildRelationshipGraphSettingsDef,
|
|
43
|
+
RELATIONSHIP_GRAPH_PANE_ID,
|
|
44
|
+
} from "./model";
|
|
39
45
|
|
|
40
46
|
type RelationshipGraphShortcut = "range" | "window" | "correlation" | "regression";
|
|
41
47
|
|
|
@@ -326,7 +332,7 @@ export function RelationshipGraphPane({ focused, width, height }: PaneProps) {
|
|
|
326
332
|
export function createRelationshipPaneTemplate(): PaneTemplateDef {
|
|
327
333
|
return {
|
|
328
334
|
id: "relationship-graph-pane",
|
|
329
|
-
paneId:
|
|
335
|
+
paneId: RELATIONSHIP_GRAPH_PANE_ID,
|
|
330
336
|
label: "Relationship Graph",
|
|
331
337
|
description: "Graph ratio, rolling correlation, and regression between two tickers.",
|
|
332
338
|
keywords: ["relationship", "ratio", "graph", "correlation", "regression", "gr"],
|
|
@@ -345,7 +351,7 @@ export function createRelationshipPaneTemplate(): PaneTemplateDef {
|
|
|
345
351
|
const pair = relationshipTemplateSymbols(context.activeTicker, options);
|
|
346
352
|
return pair
|
|
347
353
|
? {
|
|
348
|
-
title:
|
|
354
|
+
title: buildRelationshipGraphPaneTitle(pair),
|
|
349
355
|
binding: { kind: "fixed" as const, symbol: pair[0] },
|
|
350
356
|
placement: "floating" as const,
|
|
351
357
|
settings: {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createDefaultConfig } from "../../../types/config";
|
|
3
|
+
import type { Quote, TickerFinancials } from "../../../types/financials";
|
|
3
4
|
import type { BrokerAccount } from "../../../types/trading";
|
|
4
5
|
import type { TickerRecord } from "../../../types/ticker";
|
|
5
6
|
import type { PortfolioSummaryTotals } from "./metrics";
|
|
6
7
|
import { buildPortfolioSummarySegments } from "./summary";
|
|
7
8
|
import { portfolioListPlugin, shouldToggleCashMarginDrawer } from ".";
|
|
8
|
-
import { selectStreamTickers } from "./pane/data";
|
|
9
|
+
import { needsVisibleQuoteWatchdogRefresh, selectQuoteWarmupTickers, selectStreamTickers } from "./pane/data";
|
|
10
|
+
import { buildPortfolioPaneSettingsDef, getPortfolioPaneSettings } from "./settings";
|
|
9
11
|
|
|
10
12
|
function ticker(symbol: string): TickerRecord {
|
|
11
13
|
return {
|
|
@@ -132,6 +134,93 @@ describe("selectStreamTickers", () => {
|
|
|
132
134
|
});
|
|
133
135
|
});
|
|
134
136
|
|
|
137
|
+
describe("selectQuoteWarmupTickers", () => {
|
|
138
|
+
function financials(quoteValue: Quote | undefined): TickerFinancials {
|
|
139
|
+
return {
|
|
140
|
+
annualStatements: [],
|
|
141
|
+
quarterlyStatements: [],
|
|
142
|
+
priceHistory: [],
|
|
143
|
+
quote: quoteValue,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test("includes hidden quote-missing rows when sorting by quote-dependent columns", () => {
|
|
148
|
+
const tickers = Array.from({ length: 30 }, (_, index) => ticker(`T${index}`));
|
|
149
|
+
const financialsMap = new Map<string, TickerFinancials>(
|
|
150
|
+
tickers.slice(0, 29).map((entry, index) => [
|
|
151
|
+
entry.metadata.ticker,
|
|
152
|
+
financials({
|
|
153
|
+
symbol: entry.metadata.ticker,
|
|
154
|
+
price: index + 1,
|
|
155
|
+
currency: "USD",
|
|
156
|
+
change: index,
|
|
157
|
+
changePercent: index,
|
|
158
|
+
lastUpdated: 1_700_000_000_000,
|
|
159
|
+
}),
|
|
160
|
+
]),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const selected = selectQuoteWarmupTickers(
|
|
164
|
+
tickers,
|
|
165
|
+
{ start: 0, end: 24 },
|
|
166
|
+
financialsMap,
|
|
167
|
+
{ columnId: "change_pct", direction: "asc" },
|
|
168
|
+
1_700_000_010_000,
|
|
169
|
+
).map((entry) => entry.metadata.ticker);
|
|
170
|
+
|
|
171
|
+
expect(selected).toContain("T29");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("does not add hidden rows for ticker-only sorting", () => {
|
|
175
|
+
const tickers = Array.from({ length: 30 }, (_, index) => ticker(`T${index}`));
|
|
176
|
+
expect(selectQuoteWarmupTickers(
|
|
177
|
+
tickers,
|
|
178
|
+
{ start: 0, end: 24 },
|
|
179
|
+
new Map(),
|
|
180
|
+
{ columnId: "ticker", direction: "asc" },
|
|
181
|
+
).map((entry) => entry.metadata.ticker)).toEqual(
|
|
182
|
+
tickers.slice(0, 24).map((entry) => entry.metadata.ticker),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("visible quote refresh predicates", () => {
|
|
188
|
+
function quote(overrides: Partial<Quote> = {}): Quote {
|
|
189
|
+
return {
|
|
190
|
+
symbol: "AAPL",
|
|
191
|
+
price: 125,
|
|
192
|
+
currency: "USD",
|
|
193
|
+
change: 5,
|
|
194
|
+
changePercent: 4.17,
|
|
195
|
+
lastUpdated: 1_700_000_000_000,
|
|
196
|
+
...overrides,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function financials(quoteValue: Quote | undefined): TickerFinancials {
|
|
201
|
+
return {
|
|
202
|
+
annualStatements: [],
|
|
203
|
+
quarterlyStatements: [],
|
|
204
|
+
priceHistory: [],
|
|
205
|
+
quote: quoteValue,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("refreshes visible quotes when the local stream timestamp is too old", () => {
|
|
210
|
+
const now = 1_700_000_120_000;
|
|
211
|
+
expect(needsVisibleQuoteWatchdogRefresh(
|
|
212
|
+
financials(quote({ lastUpdated: now, receivedAt: now - 61_000 })),
|
|
213
|
+
now,
|
|
214
|
+
60_000,
|
|
215
|
+
)).toBe(true);
|
|
216
|
+
expect(needsVisibleQuoteWatchdogRefresh(
|
|
217
|
+
financials(quote({ lastUpdated: now, receivedAt: now - 10_000 })),
|
|
218
|
+
now,
|
|
219
|
+
60_000,
|
|
220
|
+
)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
135
224
|
describe("portfolio list pane templates", () => {
|
|
136
225
|
test("create panes with default all-collection settings", async () => {
|
|
137
226
|
const config = createDefaultConfig("/tmp/gloomberb-portfolio-list-template");
|
|
@@ -154,3 +243,15 @@ describe("portfolio list pane templates", () => {
|
|
|
154
243
|
expect(portfolioInstance).toEqual({ params: { collectionId: "main" } });
|
|
155
244
|
});
|
|
156
245
|
});
|
|
246
|
+
|
|
247
|
+
describe("portfolio list pane settings", () => {
|
|
248
|
+
test("exposes view mode only for portfolio collections", () => {
|
|
249
|
+
const config = createDefaultConfig("/tmp/gloomberb-portfolio-list-settings");
|
|
250
|
+
const settings = getPortfolioPaneSettings({ viewMode: "grid" });
|
|
251
|
+
const portfolioFields = buildPortfolioPaneSettingsDef(config, settings, "main").fields.map((field) => field.key);
|
|
252
|
+
const watchlistFields = buildPortfolioPaneSettingsDef(config, settings, "watchlist").fields.map((field) => field.key);
|
|
253
|
+
|
|
254
|
+
expect(portfolioFields).toContain("viewMode");
|
|
255
|
+
expect(watchlistFields).not.toContain("viewMode");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Quote, TickerFinancials } from "../../../../types/financials";
|
|
3
|
+
import {
|
|
4
|
+
needsVisibleQuoteWarmup,
|
|
5
|
+
needsVisibleQuoteWatchdogRefresh,
|
|
6
|
+
VISIBLE_QUOTE_STREAM_MAX_AGE_MS,
|
|
7
|
+
} from "./data";
|
|
8
|
+
|
|
9
|
+
function quote(overrides: Partial<Quote> = {}): Quote {
|
|
10
|
+
return {
|
|
11
|
+
symbol: "AAPL",
|
|
12
|
+
price: 100,
|
|
13
|
+
currency: "USD",
|
|
14
|
+
change: 0,
|
|
15
|
+
changePercent: 0,
|
|
16
|
+
lastUpdated: Date.now(),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function financials(quoteValue: Quote): TickerFinancials {
|
|
22
|
+
return {
|
|
23
|
+
annualStatements: [],
|
|
24
|
+
quarterlyStatements: [],
|
|
25
|
+
priceHistory: [],
|
|
26
|
+
quote: quoteValue,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("portfolio visible quote warmup", () => {
|
|
31
|
+
test("refreshes current-session quotes once the visible-row age window expires", () => {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const data = financials(quote({
|
|
34
|
+
lastUpdated: now - VISIBLE_QUOTE_STREAM_MAX_AGE_MS,
|
|
35
|
+
listingExchangeName: "FWB2",
|
|
36
|
+
marketState: "REGULAR",
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
expect(needsVisibleQuoteWarmup(data, now)).toBe(false);
|
|
40
|
+
expect(needsVisibleQuoteWatchdogRefresh(data, now)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("treats stale active-session quotes as visible warmup misses", () => {
|
|
44
|
+
const now = Date.parse("2026-07-07T12:10:30Z");
|
|
45
|
+
const data = financials(quote({
|
|
46
|
+
lastUpdated: Date.parse("2026-07-07T12:10:00Z"),
|
|
47
|
+
listingExchangeName: "NASDAQ",
|
|
48
|
+
marketState: "PRE",
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
expect(needsVisibleQuoteWarmup(data, now)).toBe(true);
|
|
52
|
+
expect(needsVisibleQuoteWatchdogRefresh(data, now)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -5,15 +5,37 @@ import type { TickerFinancials } from "../../../../types/financials";
|
|
|
5
5
|
import type { TickerRecord } from "../../../../types/ticker";
|
|
6
6
|
import type { CollectionSortPreference } from "../../../../state/app/context";
|
|
7
7
|
import { isQuoteStaleForCurrentSession } from "../../../../market-data/quotes/freshness";
|
|
8
|
+
import { resolveQuoteAgeTimestamp } from "../../../../market-data/quotes/time";
|
|
8
9
|
import { compareSortValues } from "../../../../utils/sort-values";
|
|
9
10
|
import { getSortValue, type ColumnContext } from "../metrics";
|
|
10
11
|
import type { ResolvedPortfolioAccountState } from "../summary";
|
|
11
12
|
|
|
12
|
-
export const
|
|
13
|
+
export const VISIBLE_QUOTE_REFRESH_COOLDOWN_MS = 15_000;
|
|
14
|
+
export const VISIBLE_QUOTE_STREAM_MAX_AGE_MS = 15_000;
|
|
15
|
+
export const VISIBLE_QUOTE_STREAM_WATCHDOG_MS = 5_000;
|
|
16
|
+
export const VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS = 5 * 60_000;
|
|
13
17
|
export const VISIBLE_FINANCIAL_WARMUP_DELAY_MS = 350;
|
|
14
18
|
export const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
|
|
19
|
+
export const SORT_QUOTE_WARMUP_BATCH_LIMIT = 64;
|
|
15
20
|
|
|
16
21
|
const STREAM_OVERSCAN_ROWS = 6;
|
|
22
|
+
const QUOTE_SORT_COLUMN_IDS = new Set([
|
|
23
|
+
"price",
|
|
24
|
+
"change",
|
|
25
|
+
"change_pct",
|
|
26
|
+
"volume",
|
|
27
|
+
"dollar_volume",
|
|
28
|
+
"range_52w",
|
|
29
|
+
"market_cap",
|
|
30
|
+
"latency",
|
|
31
|
+
"ext_hours",
|
|
32
|
+
"mkt_value",
|
|
33
|
+
"weight",
|
|
34
|
+
"day_pnl",
|
|
35
|
+
"pnl",
|
|
36
|
+
"pnl_pct",
|
|
37
|
+
"mark_delta",
|
|
38
|
+
]);
|
|
17
39
|
const FUNDAMENTAL_COLUMN_IDS = new Set(["pe", "forward_pe", "dividend_yield"]);
|
|
18
40
|
const PROFILE_COLUMN_IDS = new Set(["sector", "industry"]);
|
|
19
41
|
const sortValueCache = createRowValueCache<string, ReturnType<typeof getSortValue>>(5000);
|
|
@@ -40,6 +62,16 @@ export function needsVisibleQuoteWarmup(financials: TickerFinancials | undefined
|
|
|
40
62
|
return !financials?.quote || isQuoteStaleForCurrentSession(financials.quote, now);
|
|
41
63
|
}
|
|
42
64
|
|
|
65
|
+
export function needsVisibleQuoteWatchdogRefresh(
|
|
66
|
+
financials: TickerFinancials | undefined,
|
|
67
|
+
now = Date.now(),
|
|
68
|
+
maxAgeMs = VISIBLE_QUOTE_STREAM_MAX_AGE_MS,
|
|
69
|
+
): boolean {
|
|
70
|
+
if (needsVisibleQuoteWarmup(financials, now)) return true;
|
|
71
|
+
const quoteTimestamp = resolveQuoteAgeTimestamp(financials?.quote, now);
|
|
72
|
+
return quoteTimestamp == null || now - quoteTimestamp >= maxAgeMs;
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
export function needsVisibleSnapshotWarmup(
|
|
44
76
|
ticker: TickerRecord,
|
|
45
77
|
financials: TickerFinancials | undefined,
|
|
@@ -67,6 +99,35 @@ export function selectStreamTickers(
|
|
|
67
99
|
return selectedTicker ? [...visible, selectedTicker] : visible;
|
|
68
100
|
}
|
|
69
101
|
|
|
102
|
+
export function sortPreferenceUsesQuote(sortPreference: CollectionSortPreference): boolean {
|
|
103
|
+
return !!sortPreference.columnId && QUOTE_SORT_COLUMN_IDS.has(sortPreference.columnId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function selectQuoteWarmupTickers(
|
|
107
|
+
sortedTickers: TickerRecord[],
|
|
108
|
+
visibleRange: { start: number; end: number },
|
|
109
|
+
financialsMap: Map<string, TickerFinancials>,
|
|
110
|
+
sortPreference: CollectionSortPreference,
|
|
111
|
+
now = Date.now(),
|
|
112
|
+
hiddenLimit = SORT_QUOTE_WARMUP_BATCH_LIMIT,
|
|
113
|
+
): TickerRecord[] {
|
|
114
|
+
const visible = sortedTickers.slice(visibleRange.start, visibleRange.end);
|
|
115
|
+
if (!sortPreferenceUsesQuote(sortPreference)) {
|
|
116
|
+
return visible;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const seen = new Set(visible.map((ticker) => ticker.metadata.ticker));
|
|
120
|
+
const hidden: TickerRecord[] = [];
|
|
121
|
+
for (const ticker of sortedTickers) {
|
|
122
|
+
if (seen.has(ticker.metadata.ticker)) continue;
|
|
123
|
+
if (!needsVisibleQuoteWarmup(financialsMap.get(ticker.metadata.ticker), now)) continue;
|
|
124
|
+
hidden.push(ticker);
|
|
125
|
+
seen.add(ticker.metadata.ticker);
|
|
126
|
+
if (hidden.length >= hiddenLimit) break;
|
|
127
|
+
}
|
|
128
|
+
return [...visible, ...hidden];
|
|
129
|
+
}
|
|
130
|
+
|
|
70
131
|
export function buildTrackedCurrencies(
|
|
71
132
|
tickers: TickerRecord[],
|
|
72
133
|
financialsMap: Map<string, TickerFinancials>,
|