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.
Files changed (130) 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/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -20,24 +20,29 @@ function DefaultDesktopRow({
20
20
  selected: boolean;
21
21
  }) {
22
22
  return (
23
- <Box flexDirection="row" justifyContent="space-between" width="100%" alignItems="center">
24
- <Box flexDirection="row" alignItems="center" minWidth={0}>
25
- <Box
26
- width={1}
27
- height={1}
28
- marginRight={1}
29
- backgroundColor={selected ? colors.borderFocused : "transparent"}
30
- style={{ borderRadius: CONTROL_RADIUS }}
31
- />
23
+ <Box
24
+ flexDirection="row"
25
+ justifyContent="space-between"
26
+ width="100%"
27
+ minWidth={0}
28
+ alignItems="center"
29
+ style={{ boxSizing: "border-box" }}
30
+ >
31
+ <Box flexDirection="row" alignItems="center" minWidth={0} flexShrink={1}>
32
32
  <Text
33
33
  fg={selected ? colors.text : colors.textDim}
34
34
  attributes={selected ? TextAttributes.BOLD : 0}
35
+ style={{
36
+ overflow: "hidden",
37
+ textOverflow: "ellipsis",
38
+ whiteSpace: "nowrap",
39
+ }}
35
40
  >
36
41
  {item.label}
37
42
  </Text>
38
43
  </Box>
39
44
  {item.detail && (
40
- <Text fg={colors.textMuted}>
45
+ <Text fg={colors.textMuted} style={{ flexShrink: 0, marginLeft: 12 }}>
41
46
  {item.detail}
42
47
  </Text>
43
48
  )}
@@ -50,7 +55,11 @@ function listRowStyle(selected: boolean): CSSProperties {
50
55
  borderRadius: CONTROL_RADIUS,
51
56
  border: `1px solid ${selected ? colors.borderFocused : "transparent"}`,
52
57
  boxShadow: selected ? `inset 0 1px 0 ${blendHex(colors.bg, colors.textBright, 0.06)}` : undefined,
58
+ boxSizing: "border-box",
53
59
  cursor: "pointer",
60
+ maxWidth: "100%",
61
+ minWidth: 0,
62
+ paddingInline: 10,
54
63
  };
55
64
  }
56
65
 
@@ -69,10 +78,11 @@ export function WebListView({
69
78
  hoverBgColor,
70
79
  rowGap = 1,
71
80
  rowHeight = 1,
72
- surface = "framed",
81
+ surface,
73
82
  height,
74
83
  flexGrow,
75
84
  scrollable = false,
85
+ selectOnHover = false,
76
86
  autoScrollToIndex = true,
77
87
  }: ListViewProps) {
78
88
  useThemeColors();
@@ -83,6 +93,20 @@ export function WebListView({
83
93
  const rowHoverBg = hoverBgColor ?? hoverBg();
84
94
  const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
85
95
  const activeScrollIndex = scrollIndex ?? selectedIndex;
96
+ const effectiveSurface = surface ?? (scrollable ? "framed" : "plain");
97
+ const frameStyle = effectiveSurface === "plain"
98
+ ? {
99
+ border: "none",
100
+ borderRadius: 0,
101
+ padding: 0,
102
+ backgroundColor: "transparent",
103
+ }
104
+ : {
105
+ border: `1px solid ${panelBorder()}`,
106
+ borderRadius: CONTROL_RADIUS,
107
+ padding: 4,
108
+ backgroundColor: subtlePanelFill(),
109
+ };
86
110
 
87
111
  useEffect(() => {
88
112
  if (!scrollable || !autoScrollToIndex || activeScrollIndex < 0) return;
@@ -130,6 +154,7 @@ export function WebListView({
130
154
  onMouseMove={() => {
131
155
  if (!disabled) {
132
156
  setHoveredIndex((current) => (current === index ? current : index));
157
+ if (selectOnHover) onSelect?.(index);
133
158
  }
134
159
  }}
135
160
  onMouseDown={() => {
@@ -156,26 +181,14 @@ export function WebListView({
156
181
  flexGrow={flexGrow}
157
182
  scrollY
158
183
  focusable={false}
159
- style={surface === "plain"
160
- ? {
161
- border: "none",
162
- borderRadius: 0,
163
- padding: 0,
164
- backgroundColor: "transparent",
165
- }
166
- : {
167
- border: `1px solid ${panelBorder()}`,
168
- borderRadius: CONTROL_RADIUS,
169
- padding: 4,
170
- backgroundColor: subtlePanelFill(),
171
- }}
184
+ style={frameStyle}
172
185
  >
173
186
  <Box flexDirection="column" gap={rowGap}>
174
187
  {rows}
175
188
  </Box>
176
189
  </ScrollBox>
177
190
  ) : (
178
- <Box flexDirection="column" gap={rowGap}>
191
+ <Box flexDirection="column" gap={rowGap} style={frameStyle}>
179
192
  {rows}
180
193
  </Box>
181
194
  )}
@@ -0,0 +1,10 @@
1
+ import type { DesktopDeepLinkBridge } from "../../../types/desktop-deeplink";
2
+ import { onDesktopDeepLink } from "./backend-rpc";
3
+
4
+ export function createDesktopDeepLinkBridge(): DesktopDeepLinkBridge {
5
+ return {
6
+ subscribe(listener) {
7
+ return onDesktopDeepLink(listener);
8
+ },
9
+ };
10
+ }
@@ -19,7 +19,6 @@ import {
19
19
  cancelWebFrame,
20
20
  cellBoundsForElement,
21
21
  cellMouseEvent,
22
- hasDirectMouseHandler,
23
22
  requestWebFrame,
24
23
  } from "./mouse";
25
24
  import { cleanDomProps, commonStyle } from "./style";
@@ -148,7 +147,6 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
148
147
  <div
149
148
  {...cleanDomProps(props)}
150
149
  data-gloom-hover-bg={hoverBackgroundColor ? "true" : undefined}
151
- data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
152
150
  ref={elementRef}
153
151
  onMouseDown={handleMouseDown}
154
152
  onMouseOver={handleMouseOver}
@@ -14,7 +14,7 @@ import {
14
14
  } from "react";
15
15
  import type { ScrollBoxRenderable } from "../../../../ui/host";
16
16
  import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../input-host";
17
- import { callMouseHandler, hasDirectMouseHandler } from "./mouse";
17
+ import { callMouseHandler } from "./mouse";
18
18
  import { cleanDomProps, commonStyle } from "./style";
19
19
  import { useScrollbarActivity } from "../scrollbar-activity";
20
20
 
@@ -254,7 +254,6 @@ export const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unkno
254
254
  data-gloom-scrollbar-x={props.scrollX === true ? (horizontalScrollBarVisible ? "visible" : "hidden") : undefined}
255
255
  data-gloom-scrollbar-y={props.scrollY === true ? (verticalScrollBarVisible ? "visible" : "hidden") : undefined}
256
256
  data-gloom-scrollbar-active={scrollbarActive ? "true" : undefined}
257
- data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
258
257
  onMouseDown={(event) => callMouseHandler(props.onMouseDown, event, "down")}
259
258
  onMouseMove={(event) => callMouseHandler(props.onMouseMove, event, "move")}
260
259
  onMouseUp={(event) => callMouseHandler(props.onMouseUp, event, "up")}
@@ -9,7 +9,7 @@ import {
9
9
  } from "../../../../ui/host";
10
10
  import { WEB_CELL_WIDTH } from "../input-host";
11
11
  import { webAsciiTextLines, webAsciiTextWordmarkVariant } from "./ascii-text";
12
- import { hasDirectMouseHandler, mouseHandlers } from "./mouse";
12
+ import { mouseHandlers } from "./mouse";
13
13
  import { cleanDomProps, commonStyle, textStyle } from "./style";
14
14
 
15
15
  const WEB_WORDMARK_CHAR_WIDTH_PX = Math.max(10, WEB_CELL_WIDTH);
@@ -56,7 +56,6 @@ export function WebText({ children, ...props }: TextProps) {
56
56
  <span
57
57
  {...cleanDomProps(props)}
58
58
  {...mouseHandlers(props)}
59
- data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
60
59
  style={{ ...textStyle(props), ...(props.style as CSSProperties | undefined) }}
61
60
  >
62
61
  {renderTextContent(children, props.content, props)}
@@ -21,6 +21,7 @@ import { webNativeRenderer } from "./native-renderer";
21
21
  import { WebToastHostProvider } from "./toast-host";
22
22
  import { createWebUiHost, webRendererHost } from "./ui-host";
23
23
  import { createApplicationMenuBridge } from "./application-menu-bridge";
24
+ import { createDesktopDeepLinkBridge } from "./desktop-deeplink-bridge";
24
25
  import { createDesktopWindowBridge } from "./desktop/window/bridge";
25
26
  import { prepareDetachedSnapshot } from "./desktop/window/snapshot";
26
27
 
@@ -88,6 +89,7 @@ async function boot() {
88
89
  const config = desktopSnapshot?.config ?? init.config;
89
90
  const desktopWindowBridge = createDesktopWindowBridge(init.windowKind, init.paneId);
90
91
  const desktopApplicationMenuBridge = createApplicationMenuBridge();
92
+ const desktopDeepLinkBridge = createDesktopDeepLinkBridge();
91
93
  const webUiHost = createWebUiHost(init.desktopPlatform);
92
94
  const remoteControlAdapter = init.windowKind === "main"
93
95
  ? { registerHandler: setElectrobunRemoteRequestHandler }
@@ -103,6 +105,7 @@ async function boot() {
103
105
  config={config}
104
106
  desktopWindowBridge={desktopWindowBridge}
105
107
  desktopApplicationMenuBridge={desktopApplicationMenuBridge}
108
+ desktopDeepLinkBridge={desktopDeepLinkBridge}
106
109
  desktopSnapshot={desktopSnapshot}
107
110
  desktopThemePreview={init.desktopThemePreview}
108
111
  remoteControlAdapter={remoteControlAdapter}
@@ -7,6 +7,7 @@ import { backendRequest } from "./backend-rpc";
7
7
  import { WebDataTable } from "./data-table";
8
8
  import {
9
9
  WebButton,
10
+ WebCheckbox,
10
11
  WebDialogFrame,
11
12
  WebListView,
12
13
  WebMessageComposer,
@@ -82,6 +83,7 @@ export function createWebUiHost(desktopPlatform?: string): UiHost {
82
83
  Input: WebInput,
83
84
  Textarea: WebTextarea,
84
85
  Button: WebButton,
86
+ Checkbox: WebCheckbox,
85
87
  TextField: WebTextField,
86
88
  MessageComposer: WebMessageComposer,
87
89
  ListView: WebListView,
@@ -12,8 +12,10 @@ import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshnes
12
12
  import { resolveTickerFinancialsQuoteState } from "../../market-data/quotes/resolution";
13
13
  import { selectCachedResource } from "./cache";
14
14
  import {
15
+ dropUnusableProviderQuote,
15
16
  hasDeepStatementHistory,
16
17
  hasDetailedStatementRows,
18
+ isProviderQuoteUsableForCurrentSession,
17
19
  quoteWithFreshnessExchange,
18
20
  type CachedFinancialsSelection,
19
21
  } from "./financials";
@@ -52,8 +54,9 @@ export class ProviderRouterBatchRoutes {
52
54
  const context = target.context;
53
55
  const entityKey = this.deps.getEntityKey(target.symbol, context?.instrument);
54
56
  const variantKeys = this.deps.getTickerVariantCandidates(target.exchange);
57
+ const brokerSourceKeys = this.deps.getBrokerCandidatesForContext(context, false).map((candidate) => this.deps.brokerSourceKey(candidate));
55
58
  const sourceKeys = [
56
- ...this.deps.getBrokerCandidatesForContext(context, false).map((candidate) => this.deps.brokerSourceKey(candidate)),
59
+ ...brokerSourceKeys,
57
60
  ...this.deps.getProviderSourceKeys(),
58
61
  ];
59
62
  const rawCached = selectCachedResource<Quote>(this.deps.resources, "quote", entityKey, variantKeys, sourceKeys, false);
@@ -61,6 +64,10 @@ export class ProviderRouterBatchRoutes {
61
64
  ? rawCached
62
65
  : null;
63
66
  if (cached && !forceRefresh && !cached.stale) {
67
+ if (brokerSourceKeys.includes(cached.sourceKey)) {
68
+ misses.push({ index, target });
69
+ return;
70
+ }
64
71
  results[index] = { target, quote: cached.value };
65
72
  return;
66
73
  }
@@ -80,7 +87,7 @@ export class ProviderRouterBatchRoutes {
80
87
  const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
81
88
  const batchResults = await batchProvider.getQuotesBatch!(uniqueTargets, options).catch(() => []);
82
89
  for (const item of batchResults) {
83
- if (!item.quote || isQuoteStaleForCurrentSession(item.quote)) continue;
90
+ if (!isProviderQuoteUsableForCurrentSession(item.quote, item.target.exchange)) continue;
84
91
  const key = this.quoteBatchKey(item.target);
85
92
  const sourceKey = this.deps.providerSourceKey(batchProvider);
86
93
  for (const entry of providerIndexes.get(key) ?? []) {
@@ -142,7 +149,8 @@ export class ProviderRouterBatchRoutes {
142
149
  for (const item of batchResults) {
143
150
  if (!item.financials) continue;
144
151
  const key = this.cachedFinancialsBatchKey(item.target);
145
- const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
152
+ let value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
153
+ if (value) value = dropUnusableProviderQuote(value, item.target.exchange);
146
154
  if (!value) continue;
147
155
  const sourceKey = this.deps.providerSourceKey(batchProvider);
148
156
  for (const entry of providerIndexes.get(key) ?? []) {
@@ -405,6 +405,129 @@ describe("AssetDataRouter cached financials", () => {
405
405
  persistence.close();
406
406
  });
407
407
 
408
+ test("uses symbol provider quote reference for broker-linked snapshot reads", async () => {
409
+ const dbPath = createTempDbPath("cached-symbol-reference-for-contract-snapshot");
410
+ const persistence = new AppPersistence(dbPath);
411
+ const now = Date.now();
412
+
413
+ persistence.resources.set(
414
+ {
415
+ namespace: "market",
416
+ kind: "financials",
417
+ entityKey: "contract:275759",
418
+ variantKey: "exchange=NASDAQ",
419
+ sourceKey: "provider:gloomberb-cloud",
420
+ },
421
+ makeFinancials({
422
+ quote: makeQuote({
423
+ symbol: "VICR",
424
+ providerId: "gloomberb-cloud",
425
+ price: 292.83,
426
+ currency: "USD",
427
+ previousClose: 380.07,
428
+ change: -87.24,
429
+ changePercent: -22.95,
430
+ lastUpdated: now,
431
+ marketState: "REGULAR",
432
+ exchangeName: "NASDAQ",
433
+ listingExchangeName: "NASDAQ",
434
+ dataSource: "live",
435
+ }),
436
+ profile: {
437
+ description: "Cloud profile",
438
+ },
439
+ fundamentals: {
440
+ trailingPE: 94.3,
441
+ },
442
+ }),
443
+ {
444
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
445
+ fetchedAt: now,
446
+ },
447
+ );
448
+ persistence.resources.set(
449
+ {
450
+ namespace: "market",
451
+ kind: "financials",
452
+ entityKey: "contract:275759",
453
+ variantKey: "exchange=NASDAQ",
454
+ sourceKey: "provider:yahoo",
455
+ },
456
+ makeFinancials({
457
+ annualStatements: [{ date: "2025-12-31", totalRevenue: 100 }],
458
+ }),
459
+ {
460
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
461
+ fetchedAt: now + 1_000,
462
+ },
463
+ );
464
+ persistence.resources.set(
465
+ {
466
+ namespace: "market",
467
+ kind: "financials",
468
+ entityKey: "VICR",
469
+ variantKey: "exchange=NASDAQ",
470
+ sourceKey: "provider:yahoo",
471
+ },
472
+ makeFinancials({
473
+ quote: makeQuote({
474
+ symbol: "VICR",
475
+ providerId: "yahoo",
476
+ price: 294.39,
477
+ currency: "USD",
478
+ previousClose: 282.95,
479
+ change: 11.44,
480
+ changePercent: 4.04,
481
+ lastUpdated: now - 1_000,
482
+ marketState: "REGULAR",
483
+ exchangeName: "NASDAQ",
484
+ listingExchangeName: "NASDAQ",
485
+ dataSource: "delayed",
486
+ }),
487
+ }),
488
+ {
489
+ cachePolicy: { staleMs: 60_000, expireMs: 7 * 24 * 60 * 60_000 },
490
+ fetchedAt: now - 1_000,
491
+ },
492
+ );
493
+
494
+ const router = new AssetDataRouter({
495
+ ...fallbackProvider,
496
+ id: "yahoo",
497
+ name: "Yahoo",
498
+ async getTickerFinancials() {
499
+ throw new Error("should not fetch financials");
500
+ },
501
+ }, [{
502
+ ...fallbackProvider,
503
+ id: "gloomberb-cloud",
504
+ name: "Cloud",
505
+ priority: 100,
506
+ async getTickerFinancials() {
507
+ throw new Error("should not fetch financials");
508
+ },
509
+ }], persistence.resources);
510
+
511
+ const financials = await router.getTickerFinancials("VICR", "NASDAQ", {
512
+ brokerId: "ibkr",
513
+ brokerInstanceId: "ibkr-work",
514
+ instrument: {
515
+ brokerId: "ibkr",
516
+ brokerInstanceId: "ibkr-work",
517
+ conId: 275759,
518
+ symbol: "VICR",
519
+ },
520
+ });
521
+
522
+ expect(financials.quote?.providerId).toBe("gloomberb-cloud");
523
+ expect(financials.quote?.price).toBe(292.83);
524
+ expect(financials.quote?.previousClose).toBe(282.95);
525
+ expect(financials.quote?.changePercent).toBeCloseTo(((292.83 - 282.95) / 282.95) * 100, 10);
526
+ expect(financials.quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
527
+
528
+ persistence.close();
529
+ });
530
+
408
531
  test("overlays standalone quote cache on stale financial snapshots during startup priming", () => {
409
532
  const dbPath = createTempDbPath("cached-quote-over-financial-snapshot");
410
533
  const persistence = new AppPersistence(dbPath);
@@ -81,7 +81,9 @@ export class ProviderRouterFinancialRoutes {
81
81
  quarterlyStatements: base?.quarterlyStatements ?? [],
82
82
  priceHistory: base?.priceHistory ?? [],
83
83
  });
84
- const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false);
84
+ const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false, {
85
+ includeSymbolProviderFallback: true,
86
+ });
85
87
  const forceRefresh = context?.cacheMode === "refresh";
86
88
  if (cached.value && !forceRefresh) {
87
89
  if (isOptionTicker && !cached.value.quote) {
@@ -128,8 +130,9 @@ export class ProviderRouterFinancialRoutes {
128
130
  async getQuote(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<Quote> {
129
131
  const entityKey = this.deps.getEntityKey(ticker, context?.instrument);
130
132
  const variantKeys = this.deps.getTickerVariantCandidates(exchange);
133
+ const brokerSourceKeys = this.deps.getBrokerCandidatesForContext(context, false).map((candidate) => this.deps.brokerSourceKey(candidate));
131
134
  const sourceKeys = [
132
- ...this.deps.getBrokerCandidatesForContext(context, false).map((candidate) => this.deps.brokerSourceKey(candidate)),
135
+ ...brokerSourceKeys,
133
136
  ...this.deps.getProviderSourceKeys(),
134
137
  ];
135
138
  const rawCached = selectCachedResource<Quote>(this.deps.resources, "quote", entityKey, variantKeys, sourceKeys, false);
@@ -138,12 +141,19 @@ export class ProviderRouterFinancialRoutes {
138
141
  : null;
139
142
  const forceRefresh = context?.cacheMode === "refresh";
140
143
  if (cached && !forceRefresh && !cached.stale) {
141
- return cached.value;
144
+ if (!brokerSourceKeys.includes(cached.sourceKey)) return cached.value;
145
+ return this.mergeBrokerQuoteWithProviderReference(
146
+ cached.value,
147
+ await this.getProviderReferenceQuote(ticker, exchange, context),
148
+ );
142
149
  }
143
150
 
144
151
  const brokerQuote = await withBrokerTimeout(this.deps.primaryRoutes.fetchBrokerQuote(ticker, exchange, context));
145
152
  if (brokerQuote && !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(brokerQuote.value, exchange))) {
146
- return brokerQuote.value;
153
+ return this.mergeBrokerQuoteWithProviderReference(
154
+ brokerQuote.value,
155
+ await this.getProviderReferenceQuote(ticker, exchange, context),
156
+ );
147
157
  }
148
158
 
149
159
  const providerQuote = await this.deps.primaryRoutes.fetchProviderQuote(ticker, exchange, context);
@@ -154,6 +164,45 @@ export class ProviderRouterFinancialRoutes {
154
164
  throw new Error(`No quote provider available for ${ticker}`);
155
165
  }
156
166
 
167
+ private async getProviderReferenceQuote(
168
+ ticker: string,
169
+ exchange?: string,
170
+ context?: MarketDataRequestContext,
171
+ ): Promise<Quote | null> {
172
+ const cached = this.readCachedProviderQuote(ticker, exchange, context);
173
+ if (cached) return cached;
174
+ const providerQuote = await this.deps.primaryRoutes.fetchProviderQuote(ticker, exchange, context);
175
+ return providerQuote?.value ?? null;
176
+ }
177
+
178
+ private readCachedProviderQuote(
179
+ ticker: string,
180
+ exchange?: string,
181
+ context?: MarketDataRequestContext,
182
+ ): Quote | null {
183
+ const entityKey = this.deps.getEntityKey(ticker, context?.instrument);
184
+ const entityKeys = [...new Set([entityKey, normalizeTicker(ticker)])];
185
+ const variantKeys = this.deps.getTickerVariantCandidates(exchange);
186
+ const sourceKeys = this.deps.getProviderSourceKeys();
187
+ for (const candidateEntityKey of entityKeys) {
188
+ const record = selectCachedResource<Quote>(this.deps.resources, "quote", candidateEntityKey, variantKeys, sourceKeys, false);
189
+ if (record && !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(record.value, exchange))) {
190
+ return record.value;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ private mergeBrokerQuoteWithProviderReference(brokerQuote: Quote, providerQuote: Quote | null): Quote {
197
+ if (!providerQuote) return brokerQuote;
198
+ return resolveTickerFinancialsQuoteState({
199
+ quote: providerQuote,
200
+ annualStatements: [],
201
+ quarterlyStatements: [],
202
+ priceHistory: [],
203
+ }, brokerQuote)?.quote ?? brokerQuote;
204
+ }
205
+
157
206
  readCachedMergedFinancialsSelection(
158
207
  ticker: string,
159
208
  exchange?: string,
@@ -171,7 +220,8 @@ export class ProviderRouterFinancialRoutes {
171
220
  ? { ...brokerRecord, value: sanitizeCachedFinancials(brokerRecord.value, options) }
172
221
  : null;
173
222
  const providerSourceKeys = this.deps.getProviderSourceKeys();
174
- const providerEntityKeys = options.includeSymbolProviderFallback
223
+ const includeSymbolProviderFallback = options.includeSymbolProviderFallback !== false;
224
+ const providerEntityKeys = includeSymbolProviderFallback
175
225
  ? [...new Set([entityKey, normalizeTicker(ticker)])]
176
226
  : [entityKey];
177
227
  const providerRecords = sortCachedRecords(
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ dropUnusableProviderQuote,
4
+ isProviderQuoteUsableForCurrentSession,
5
+ } from "./financials";
6
+ import { makeFinancials, makeQuote } from "./test-support";
7
+
8
+ describe("provider-router financial quote usability", () => {
9
+ test("rejects active-session labels without active-session prices", () => {
10
+ expect(isProviderQuoteUsableForCurrentSession(makeQuote({
11
+ listingExchangeName: "NASDAQ",
12
+ marketState: "PRE",
13
+ lastUpdated: Date.now(),
14
+ }), "NASDAQ")).toBe(false);
15
+ });
16
+
17
+ test("rejects old active-session provider quotes", () => {
18
+ expect(isProviderQuoteUsableForCurrentSession(makeQuote({
19
+ listingExchangeName: "FWB2",
20
+ marketState: "REGULAR",
21
+ lastUpdated: Date.now() - 20 * 60_000,
22
+ }), "FWB2")).toBe(false);
23
+ });
24
+
25
+ test("rejects empty zero provider quotes", () => {
26
+ expect(isProviderQuoteUsableForCurrentSession(makeQuote({
27
+ price: 0,
28
+ change: 0,
29
+ changePercent: 0,
30
+ listingExchangeName: "SFB",
31
+ lastUpdated: Date.now(),
32
+ }), "SFB")).toBe(false);
33
+ });
34
+
35
+ test("strips unusable quotes while preserving non-quote financials", () => {
36
+ const value = dropUnusableProviderQuote(makeFinancials({
37
+ profile: { sector: "Industrials" },
38
+ quote: makeQuote({
39
+ price: 0,
40
+ change: 0,
41
+ changePercent: 0,
42
+ listingExchangeName: "SFB",
43
+ }),
44
+ }), "SFB");
45
+
46
+ expect(value.profile?.sector).toBe("Industrials");
47
+ expect(value.quote).toBeUndefined();
48
+ });
49
+ });
@@ -77,6 +77,46 @@ export function quoteWithFreshnessExchange(quote: Quote, exchange?: string): Quo
77
77
  };
78
78
  }
79
79
 
80
+ function finitePositiveNumber(value: unknown): value is number {
81
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
82
+ }
83
+
84
+ const ACTIVE_PROVIDER_QUOTE_MAX_AGE_MS = 10 * 60_000;
85
+ const ACTIVE_QUOTE_MARKET_STATES = new Set(["PRE", "REGULAR", "POST"]);
86
+
87
+ function isActiveProviderQuoteTooOld(quote: Quote, now = Date.now()): boolean {
88
+ if (!ACTIVE_QUOTE_MARKET_STATES.has(quote.marketState ?? "")) return false;
89
+ if (!Number.isFinite(quote.lastUpdated)) return false;
90
+ return now - quote.lastUpdated > ACTIVE_PROVIDER_QUOTE_MAX_AGE_MS;
91
+ }
92
+
93
+ export function isProviderQuoteUsableForCurrentSession(quote: Quote | null | undefined, exchange?: string): quote is Quote {
94
+ if (!quote) return false;
95
+ const normalized = quoteWithFreshnessExchange(quote, exchange);
96
+ if (isQuoteStaleForCurrentSession(normalized)) return false;
97
+ if (isActiveProviderQuoteTooOld(normalized)) return false;
98
+ return [
99
+ normalized.price,
100
+ normalized.preMarketPrice,
101
+ normalized.postMarketPrice,
102
+ normalized.bid,
103
+ normalized.ask,
104
+ normalized.mark,
105
+ ].some(finitePositiveNumber);
106
+ }
107
+
108
+ export function dropUnusableProviderQuote(value: TickerFinancials, exchange?: string): TickerFinancials {
109
+ if (!value.quote || isProviderQuoteUsableForCurrentSession(value.quote, exchange)) {
110
+ return value;
111
+ }
112
+
113
+ return {
114
+ ...value,
115
+ quote: undefined,
116
+ quoteContributions: undefined,
117
+ };
118
+ }
119
+
80
120
  function sanitizeCachedQuote(
81
121
  quote: Quote,
82
122
  exchange: string | undefined,
@@ -216,13 +256,10 @@ export function mergeCachedFinancialRecords(
216
256
  value: TickerFinancials | null;
217
257
  stale: boolean;
218
258
  } {
219
- const seenSources = new Set<string>();
220
259
  let merged: TickerFinancials | null = null;
221
260
  let stale = false;
222
261
 
223
262
  for (const record of records) {
224
- if (seenSources.has(record.sourceKey)) continue;
225
- seenSources.add(record.sourceKey);
226
263
  merged = mergeFinancials(merged, sanitizeCachedFinancials(record.value, options));
227
264
  stale = stale || record.stale === true;
228
265
  }