gloomberb 0.9.7 → 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.
Files changed (126) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/plugins/builtin/account-management/footer.ts +2 -5
  41. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  42. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  43. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  44. package/src/plugins/builtin/account-management/model.ts +263 -3
  45. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  46. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  47. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  48. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  49. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  50. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  51. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  52. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  53. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  54. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  55. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  56. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  57. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  58. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  59. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  60. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  61. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  62. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  63. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  64. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  65. package/src/plugins/builtin/correlation/index.tsx +4 -1
  66. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  67. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  68. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  69. package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
  70. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  71. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  72. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  73. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  74. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  75. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  76. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  77. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  80. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  81. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  82. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  83. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  84. package/src/plugins/registry/context.ts +15 -0
  85. package/src/plugins/registry/index.ts +31 -0
  86. package/src/renderers/electrobun/bun/index.ts +42 -0
  87. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  88. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  89. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  90. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  91. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  92. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  93. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  94. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  95. package/src/renderers/electrobun/view/main.tsx +3 -0
  96. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  97. package/src/sources/provider-router/batches.ts +11 -3
  98. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  99. package/src/sources/provider-router/financial-routes.ts +55 -5
  100. package/src/sources/provider-router/financials.test.ts +49 -0
  101. package/src/sources/provider-router/financials.ts +40 -3
  102. package/src/sources/provider-router/index.test.ts +147 -0
  103. package/src/sources/provider-router/primary.ts +17 -3
  104. package/src/sources/provider-router/streaming.ts +6 -1
  105. package/src/sources/sec-edgar.test.ts +42 -0
  106. package/src/sources/sec-edgar.ts +1 -2
  107. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  108. package/src/sources/yahoo-finance/mappers.ts +2 -1
  109. package/src/sources/yahoo-finance/requests.ts +2 -1
  110. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  111. package/src/sources/yahoo-finance.ts +6 -2
  112. package/src/state/hooks/quote-streaming.test.ts +14 -1
  113. package/src/state/hooks/quote-streaming.ts +5 -4
  114. package/src/sync/controller.ts +296 -0
  115. package/src/sync/core-contributors.test.ts +251 -0
  116. package/src/sync/core-contributors.ts +400 -0
  117. package/src/sync/profile-analytics.ts +39 -0
  118. package/src/sync/react.ts +53 -0
  119. package/src/sync/types.ts +77 -0
  120. package/src/types/desktop-deeplink.ts +7 -0
  121. package/src/types/plugin.ts +3 -0
  122. package/src/ui/host.tsx +14 -1
  123. package/src/ui/index.tsx +1 -0
  124. package/src/utils/exchanges.test.ts +11 -1
  125. package/src/utils/exchanges.ts +5 -1
  126. 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: "relationship-graph",
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 + 5}
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
- <Text fg={checked ? colors.text : colors.textDim}>{checked ? "[x]" : "[ ]"} {label}</Text>
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 { buildRelationshipAnalysis } from "./model";
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: "relationship-graph",
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: `GR ${pair[0]}/${pair[1]}`,
354
+ title: buildRelationshipGraphPaneTitle(pair),
349
355
  binding: { kind: "fixed" as const, symbol: pair[0] },
350
356
  placement: "floating" as const,
351
357
  settings: {
@@ -6,7 +6,7 @@ import type { TickerRecord } from "../../../types/ticker";
6
6
  import type { PortfolioSummaryTotals } from "./metrics";
7
7
  import { buildPortfolioSummarySegments } from "./summary";
8
8
  import { portfolioListPlugin, shouldToggleCashMarginDrawer } from ".";
9
- import { needsVisibleQuoteWatchdogRefresh, selectStreamTickers } from "./pane/data";
9
+ import { needsVisibleQuoteWatchdogRefresh, selectQuoteWarmupTickers, selectStreamTickers } from "./pane/data";
10
10
  import { buildPortfolioPaneSettingsDef, getPortfolioPaneSettings } from "./settings";
11
11
 
12
12
  function ticker(symbol: string): TickerRecord {
@@ -134,6 +134,56 @@ describe("selectStreamTickers", () => {
134
134
  });
135
135
  });
136
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
+
137
187
  describe("visible quote refresh predicates", () => {
138
188
  function quote(overrides: Partial<Quote> = {}): Quote {
139
189
  return {
@@ -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
+ });
@@ -10,14 +10,32 @@ import { compareSortValues } from "../../../../utils/sort-values";
10
10
  import { getSortValue, type ColumnContext } from "../metrics";
11
11
  import type { ResolvedPortfolioAccountState } from "../summary";
12
12
 
13
- export const VISIBLE_QUOTE_REFRESH_COOLDOWN_MS = 60_000;
14
- export const VISIBLE_QUOTE_STREAM_MAX_AGE_MS = 60_000;
15
- export const VISIBLE_QUOTE_STREAM_WATCHDOG_MS = 30_000;
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
16
  export const VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS = 5 * 60_000;
17
17
  export const VISIBLE_FINANCIAL_WARMUP_DELAY_MS = 350;
18
18
  export const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
19
+ export const SORT_QUOTE_WARMUP_BATCH_LIMIT = 64;
19
20
 
20
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
+ ]);
21
39
  const FUNDAMENTAL_COLUMN_IDS = new Set(["pe", "forward_pe", "dividend_yield"]);
22
40
  const PROFILE_COLUMN_IDS = new Set(["sector", "industry"]);
23
41
  const sortValueCache = createRowValueCache<string, ReturnType<typeof getSortValue>>(5000);
@@ -81,6 +99,35 @@ export function selectStreamTickers(
81
99
  return selectedTicker ? [...visible, selectedTicker] : visible;
82
100
  }
83
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
+
84
131
  export function buildTrackedCurrencies(
85
132
  tickers: TickerRecord[],
86
133
  financialsMap: Map<string, TickerFinancials>,