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
@@ -0,0 +1,76 @@
1
+ import { type ComponentType } from "react";
2
+ import { Box, Text, TextAttributes, useUiHost, type HostCheckboxProps } from "../../ui";
3
+ import { colors, hoverBg } from "../../theme/colors";
4
+ import { useRemoteUiNode } from "../../remote/semantic-tree";
5
+
6
+ export type CheckboxProps = HostCheckboxProps;
7
+
8
+ export function Checkbox({
9
+ label,
10
+ displayLabel,
11
+ checked,
12
+ onChange,
13
+ disabled = false,
14
+ active = false,
15
+ description,
16
+ width,
17
+ variant = "default",
18
+ }: CheckboxProps) {
19
+ useRemoteUiNode({
20
+ role: "checkbox",
21
+ label,
22
+ disabled,
23
+ actions: {
24
+ toggle: () => {
25
+ if (!disabled) onChange?.(!checked);
26
+ },
27
+ press: () => {
28
+ if (!disabled) onChange?.(!checked);
29
+ },
30
+ },
31
+ metadata: { checked, active, variant },
32
+ });
33
+
34
+ const HostCheckbox = useUiHost().Checkbox as ComponentType<CheckboxProps> | undefined;
35
+ if (HostCheckbox) {
36
+ return (
37
+ <HostCheckbox
38
+ label={label}
39
+ displayLabel={displayLabel}
40
+ checked={checked}
41
+ onChange={onChange}
42
+ disabled={disabled}
43
+ active={active}
44
+ description={description}
45
+ width={width}
46
+ variant={variant}
47
+ />
48
+ );
49
+ }
50
+
51
+ const marker = checked ? "\u2713" : " ";
52
+ const visibleLabel = displayLabel ?? label;
53
+ const fg = disabled ? colors.textMuted : active ? colors.textBright : colors.text;
54
+ const descriptionWidth = typeof width === "number" ? Math.max(20, width - 2) : 28;
55
+ return (
56
+ <Box
57
+ flexDirection="column"
58
+ width={width}
59
+ backgroundColor={active && !disabled ? hoverBg() : undefined}
60
+ onMouseDown={(event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
61
+ event?.preventDefault?.();
62
+ event?.stopPropagation?.();
63
+ if (!disabled) onChange?.(!checked);
64
+ }}
65
+ >
66
+ <Text fg={fg} attributes={active ? TextAttributes.BOLD : 0}>
67
+ {`${active ? "> " : " "}[${marker}] ${visibleLabel}`}
68
+ </Text>
69
+ {description ? (
70
+ <Text fg={colors.textMuted} wrapText width={descriptionWidth}>
71
+ {description}
72
+ </Text>
73
+ ) : null}
74
+ </Box>
75
+ );
76
+ }
@@ -37,6 +37,15 @@ function getInitialChoiceIndex(choices: ChoiceDialogChoice[], selectedChoiceId:
37
37
  return selectedIndex >= 0 ? selectedIndex : 0;
38
38
  }
39
39
 
40
+ function choiceDialogWidth(title: string, choices: ChoiceDialogChoice[]): number {
41
+ const contentWidth = Math.max(
42
+ title.length,
43
+ ...choices.map((choice) => choice.label.length + (choice.detail ? choice.detail.length + 4 : 0)),
44
+ ...choices.map((choice) => getChoiceDescription(choice).length),
45
+ );
46
+ return Math.max(34, Math.min(76, contentWidth + 4));
47
+ }
48
+
40
49
  export function ChoiceDialog({
41
50
  resolve,
42
51
  title,
@@ -57,6 +66,7 @@ export function ChoiceDialog({
57
66
  detail: choice.detail,
58
67
  disabled: choice.disabled,
59
68
  })), [choices]);
69
+ const width = useMemo(() => choiceDialogWidth(title, choices), [choices, title]);
60
70
 
61
71
  useEffect(() => {
62
72
  setIndex((current) => clampChoiceIndex(current, choices.length));
@@ -81,19 +91,21 @@ export function ChoiceDialog({
81
91
  });
82
92
 
83
93
  return (
84
- <DialogFrame title={title} footer={footer}>
85
- <Box flexDirection="column">
94
+ <DialogFrame title={title} footer={footer} showTitleDivider={false}>
95
+ <Box flexDirection="column" width={width}>
86
96
  <ListView
87
97
  items={items}
88
98
  selectedIndex={selectedIndex}
89
99
  bgColor={bgColor}
90
100
  emptyMessage="No choices."
101
+ rowGap={0}
102
+ surface="framed"
91
103
  selectOnHover
92
104
  onSelect={setIndex}
93
105
  onActivate={(_, nextIndex) => activateChoice(choices[nextIndex])}
94
106
  />
95
107
  <Box height={1} />
96
- <Text fg={colors.textDim}>{getChoiceDescription(selectedChoice)}</Text>
108
+ <Text fg={colors.textDim} wrapText width={width}>{getChoiceDescription(selectedChoice)}</Text>
97
109
  </Box>
98
110
  </DialogFrame>
99
111
  );
@@ -11,7 +11,7 @@ export interface DialogFrameProps {
11
11
  showTitleDivider?: boolean;
12
12
  }
13
13
 
14
- export function DialogFrame({ title, children, footer, showTitleDivider = true }: DialogFrameProps) {
14
+ export function DialogFrame({ title, children, footer, showTitleDivider = false }: DialogFrameProps) {
15
15
  useThemeColors();
16
16
  const HostDialogFrame = useUiHost().DialogFrame as ComponentType<DialogFrameProps> | undefined;
17
17
  if (HostDialogFrame) {
@@ -6,6 +6,8 @@ export { DataTable } from "./data-table";
6
6
  export type { DataTableCell, DataTableColumn, DataTableProps } from "./data-table";
7
7
 
8
8
  export { Button } from "./button";
9
+ export { Checkbox } from "./checkbox";
10
+ export type { CheckboxProps } from "./checkbox";
9
11
  export { ShortcutHint } from "./shortcut-hint";
10
12
 
11
13
  export { MultiSelectDialogButton, MultiSelectDialogContent } from "./multi-select/dialog";
@@ -17,6 +17,7 @@ export interface MessageComposerProps {
17
17
  terminalBottomInset?: number;
18
18
  onFocusRequest?: () => void;
19
19
  onInput?: (value: string) => void;
20
+ onCursorChange?: () => void;
20
21
  onSubmit?: () => void;
21
22
  keyBindings?: Array<Record<string, unknown>>;
22
23
  wrapText?: boolean;
@@ -52,6 +53,7 @@ export function MessageComposer({
52
53
  terminalBottomInset = 0,
53
54
  onFocusRequest,
54
55
  onInput,
56
+ onCursorChange,
55
57
  onSubmit,
56
58
  keyBindings,
57
59
  wrapText = false,
@@ -91,6 +93,7 @@ export function MessageComposer({
91
93
  terminalBottomInset={terminalBottomInset}
92
94
  onFocusRequest={onFocusRequest}
93
95
  onInput={onInput}
96
+ onCursorChange={onCursorChange}
94
97
  onSubmit={onSubmit}
95
98
  keyBindings={keyBindings}
96
99
  wrapText={wrapText}
@@ -149,6 +152,7 @@ export function MessageComposer({
149
152
  focusedBackgroundColor={colors.bg}
150
153
  cursorColor={colors.textBright}
151
154
  onInput={handleInput}
155
+ onCursorChange={onCursorChange}
152
156
  keyBindings={keyBindings}
153
157
  onSubmit={onSubmit}
154
158
  wrapText={wrapText}
@@ -17,6 +17,7 @@ export interface NativeSelectProps {
17
17
  value: string;
18
18
  options: NativeSelectOption[];
19
19
  width?: number | string;
20
+ height?: number;
20
21
  includeUnsetOption?: boolean;
21
22
  selectRef?: (element: NativeSelectElement | null) => void;
22
23
  onFocus?: () => void;
@@ -41,6 +42,7 @@ export function NativeSelect({
41
42
  value,
42
43
  options,
43
44
  width = 184,
45
+ height = 28,
44
46
  includeUnsetOption = false,
45
47
  selectRef,
46
48
  onFocus,
@@ -49,7 +51,7 @@ export function NativeSelect({
49
51
  const hasCurrentValue = options.some((option) => option.value === value);
50
52
  const style: CSSProperties = {
51
53
  width,
52
- height: 28,
54
+ height,
53
55
  color: colors.text,
54
56
  backgroundColor: blendHex(colors.panel, colors.textBright, 0.06),
55
57
  border: `1px solid ${colors.border}`,
@@ -66,7 +68,7 @@ export function NativeSelect({
66
68
 
67
69
  return (
68
70
  <Box
69
- height="28px"
71
+ height={`${height}px`}
70
72
  flexDirection="row"
71
73
  alignItems="center"
72
74
  onMouseDown={(event: any) => {
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MarketDataCoordinatorEvents } from "./events";
3
+
4
+ const waitMicrotask = () => Promise.resolve();
5
+ const waitTimer = () => new Promise((resolve) => setTimeout(resolve, 0));
6
+
7
+ describe("MarketDataCoordinatorEvents", () => {
8
+ test("coalesces version bumps before notifying external-store listeners", async () => {
9
+ const events = new MarketDataCoordinatorEvents();
10
+ const calls: number[] = [];
11
+ events.subscribeKeys(["quote:AMD"], () => {
12
+ calls.push(events.getKeysVersion(["quote:AMD"]));
13
+ });
14
+
15
+ events.bump("quote:AMD");
16
+ events.bump("quote:AMD");
17
+
18
+ expect(events.getKeysVersion(["quote:AMD"])).toBe(0);
19
+ expect(calls).toEqual([]);
20
+
21
+ await waitMicrotask();
22
+ expect(events.getKeysVersion(["quote:AMD"])).toBe(1);
23
+ expect(calls).toEqual([]);
24
+
25
+ await waitTimer();
26
+ expect(calls).toEqual([1]);
27
+ });
28
+
29
+ test("delivers bumps scheduled during notification in a later notification pass", async () => {
30
+ const events = new MarketDataCoordinatorEvents();
31
+ const order: string[] = [];
32
+ events.subscribeKeys(["quote:AMD"], () => {
33
+ order.push("AMD");
34
+ events.bump("quote:NVDA");
35
+ });
36
+ events.subscribeKeys(["quote:NVDA"], () => {
37
+ order.push("NVDA");
38
+ });
39
+
40
+ events.bump("quote:AMD");
41
+ await waitMicrotask();
42
+ await waitTimer();
43
+ expect(order).toEqual(["AMD"]);
44
+
45
+ await waitMicrotask();
46
+ await waitTimer();
47
+ expect(order).toEqual(["AMD", "NVDA"]);
48
+ });
49
+ });
@@ -4,6 +4,8 @@ export class MarketDataCoordinatorEvents {
4
4
  private version = 0;
5
5
  private pendingVersionBump = false;
6
6
  private pendingChangedKeys = new Set<string>();
7
+ private pendingNotify = false;
8
+ private pendingListeners = new Set<() => void>();
7
9
  private readonly listeners = new Set<() => void>();
8
10
  private readonly keyListeners = new Map<string, Set<() => void>>();
9
11
  private readonly keyVersions = new Map<string, number>();
@@ -60,15 +62,34 @@ export class MarketDataCoordinatorEvents {
60
62
  for (const key of changedKeys) {
61
63
  this.keyVersions.set(key, (this.keyVersions.get(key) ?? 0) + 1);
62
64
  }
63
- const keyListeners = new Set<() => void>();
65
+ for (const listener of this.listeners) {
66
+ this.pendingListeners.add(listener);
67
+ }
64
68
  for (const key of changedKeys) {
65
69
  for (const listener of this.keyListeners.get(key) ?? []) {
66
- keyListeners.add(listener);
70
+ this.pendingListeners.add(listener);
67
71
  }
68
72
  }
69
73
 
70
- for (const listener of this.listeners) listener();
71
- for (const listener of keyListeners) listener();
74
+ this.scheduleNotify();
72
75
  }, { changedKeyCount: changedKeys.size });
73
76
  }
77
+
78
+ private scheduleNotify(): void {
79
+ if (this.pendingNotify) return;
80
+ this.pendingNotify = true;
81
+ setTimeout(() => this.flushNotify(), 0);
82
+ }
83
+
84
+ private flushNotify(): void {
85
+ this.pendingNotify = false;
86
+ const listeners = [...this.pendingListeners];
87
+ this.pendingListeners.clear();
88
+ for (const listener of listeners) {
89
+ listener();
90
+ }
91
+ if (this.pendingListeners.size > 0) {
92
+ this.scheduleNotify();
93
+ }
94
+ }
74
95
  }
@@ -347,6 +347,229 @@ describe("MarketDataCoordinator", () => {
347
347
  expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(412.5);
348
348
  });
349
349
 
350
+ it("reconciles stream quotes with cached provider day references before a snapshot loads", () => {
351
+ let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
352
+ const provider = createProvider({
353
+ getCachedFinancialsForTargets: () => new Map([[
354
+ "VICR",
355
+ {
356
+ quote: {
357
+ symbol: "VICR",
358
+ providerId: "gloomberb-cloud",
359
+ price: 292.83,
360
+ currency: "USD",
361
+ previousClose: 282.95,
362
+ change: 9.88,
363
+ changePercent: 3.49,
364
+ lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
365
+ marketState: "REGULAR",
366
+ dataSource: "live",
367
+ },
368
+ quoteContributions: {
369
+ "gloomberb-cloud": {
370
+ symbol: "VICR",
371
+ providerId: "gloomberb-cloud",
372
+ price: 292.83,
373
+ currency: "USD",
374
+ previousClose: 380.07,
375
+ change: -87.24,
376
+ changePercent: -22.95,
377
+ lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
378
+ marketState: "REGULAR",
379
+ dataSource: "live",
380
+ },
381
+ yahoo: {
382
+ symbol: "VICR",
383
+ providerId: "yahoo",
384
+ price: 294.39,
385
+ currency: "USD",
386
+ previousClose: 282.95,
387
+ change: 11.44,
388
+ changePercent: 4.04,
389
+ lastUpdated: Date.parse("2026-07-06T16:45:37Z"),
390
+ marketState: "REGULAR",
391
+ dataSource: "delayed",
392
+ },
393
+ },
394
+ annualStatements: [],
395
+ quarterlyStatements: [],
396
+ priceHistory: [],
397
+ },
398
+ ]]),
399
+ subscribeQuotes: (_targets, onQuote) => {
400
+ streamed = onQuote as typeof streamed;
401
+ return () => {};
402
+ },
403
+ });
404
+ const coordinator = new MarketDataCoordinator(provider);
405
+ const instrument = {
406
+ symbol: "VICR",
407
+ exchange: "NASDAQ",
408
+ brokerId: "ibkr",
409
+ brokerInstanceId: "ibkr-work",
410
+ instrument: {
411
+ brokerId: "ibkr",
412
+ brokerInstanceId: "ibkr-work",
413
+ conId: 275759,
414
+ symbol: "VICR",
415
+ },
416
+ };
417
+
418
+ coordinator.subscribeQuotes([{ instrument }]);
419
+ const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
420
+ if (!onStreamed) throw new Error("expected streaming callback");
421
+ onStreamed(
422
+ {
423
+ symbol: "VICR",
424
+ exchange: "NASDAQ",
425
+ context: {
426
+ brokerId: "ibkr",
427
+ brokerInstanceId: "ibkr-work",
428
+ instrument: instrument.instrument,
429
+ },
430
+ },
431
+ {
432
+ symbol: "VICR",
433
+ providerId: "gloomberb-cloud",
434
+ price: 293.07,
435
+ currency: "USD",
436
+ previousClose: 380.07,
437
+ change: -87,
438
+ changePercent: -22.89,
439
+ lastUpdated: Date.parse("2026-07-06T17:17:00Z"),
440
+ marketState: "REGULAR",
441
+ dataSource: "live",
442
+ },
443
+ );
444
+
445
+ const quote = coordinator.getQuoteEntry(instrument).data;
446
+ expect(quote?.price).toBe(293.07);
447
+ expect(quote?.previousClose).toBe(282.95);
448
+ expect(quote?.changePercent).toBeCloseTo(((293.07 - 282.95) / 282.95) * 100, 10);
449
+ expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
450
+ });
451
+
452
+ it("reconciles batch quote loads with cached provider day references before a snapshot loads", async () => {
453
+ const provider = createProvider({
454
+ getCachedFinancialsForTargets: () => new Map([[
455
+ "VICR",
456
+ {
457
+ quote: {
458
+ symbol: "VICR",
459
+ providerId: "gloomberb-cloud",
460
+ price: 292.83,
461
+ currency: "USD",
462
+ previousClose: 282.95,
463
+ change: 9.88,
464
+ changePercent: 3.49,
465
+ lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
466
+ marketState: "REGULAR",
467
+ dataSource: "live",
468
+ },
469
+ quoteContributions: {
470
+ "gloomberb-cloud": {
471
+ symbol: "VICR",
472
+ providerId: "gloomberb-cloud",
473
+ price: 292.83,
474
+ currency: "USD",
475
+ previousClose: 380.07,
476
+ change: -87.24,
477
+ changePercent: -22.95,
478
+ lastUpdated: Date.parse("2026-07-06T16:54:30Z"),
479
+ marketState: "REGULAR",
480
+ dataSource: "live",
481
+ },
482
+ yahoo: {
483
+ symbol: "VICR",
484
+ providerId: "yahoo",
485
+ price: 294.39,
486
+ currency: "USD",
487
+ previousClose: 282.95,
488
+ change: 11.44,
489
+ changePercent: 4.04,
490
+ lastUpdated: Date.parse("2026-07-06T16:45:37Z"),
491
+ marketState: "REGULAR",
492
+ dataSource: "delayed",
493
+ },
494
+ },
495
+ annualStatements: [],
496
+ quarterlyStatements: [],
497
+ priceHistory: [],
498
+ },
499
+ ]]),
500
+ getQuotesBatch: async (targets) => targets.map((target) => ({
501
+ target,
502
+ quote: {
503
+ symbol: target.symbol,
504
+ providerId: "gloomberb-cloud",
505
+ price: 293.07,
506
+ currency: "USD",
507
+ previousClose: 380.07,
508
+ change: -87,
509
+ changePercent: -22.89,
510
+ lastUpdated: Date.parse("2026-07-06T17:17:00Z"),
511
+ marketState: "REGULAR",
512
+ dataSource: "live",
513
+ },
514
+ })),
515
+ });
516
+ const coordinator = new MarketDataCoordinator(provider);
517
+ const instrument = {
518
+ symbol: "VICR",
519
+ exchange: "NASDAQ",
520
+ brokerId: "ibkr",
521
+ brokerInstanceId: "ibkr-work",
522
+ instrument: {
523
+ brokerId: "ibkr",
524
+ brokerInstanceId: "ibkr-work",
525
+ conId: 275759,
526
+ symbol: "VICR",
527
+ },
528
+ };
529
+
530
+ await coordinator.loadQuotesBatch([instrument], { forceRefresh: true });
531
+
532
+ const quote = coordinator.getQuoteEntry(instrument).data;
533
+ expect(quote?.price).toBe(293.07);
534
+ expect(quote?.previousClose).toBe(282.95);
535
+ expect(quote?.changePercent).toBeCloseTo(((293.07 - 282.95) / 282.95) * 100, 10);
536
+ expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
537
+ });
538
+
539
+ it("preserves requested quote stream routes", () => {
540
+ let subscribedTargets: QuoteSubscriptionTarget[] = [];
541
+ const provider = createProvider({
542
+ subscribeQuotes: (targets) => {
543
+ subscribedTargets = targets;
544
+ return () => {};
545
+ },
546
+ });
547
+ const coordinator = new MarketDataCoordinator(provider);
548
+
549
+ coordinator.subscribeQuotes([{
550
+ instrument: {
551
+ symbol: "AAPL",
552
+ exchange: "NASDAQ",
553
+ brokerId: "ibkr",
554
+ brokerInstanceId: "ibkr-work",
555
+ },
556
+ priority: { route: "provider", surface: "portfolio", visible: true },
557
+ }]);
558
+
559
+ expect(subscribedTargets).toEqual([{
560
+ symbol: "AAPL",
561
+ exchange: "NASDAQ",
562
+ route: "provider",
563
+ context: {
564
+ brokerId: "ibkr",
565
+ brokerInstanceId: "ibkr-work",
566
+ instrument: null,
567
+ },
568
+ surface: "portfolio",
569
+ visible: true,
570
+ }]);
571
+ });
572
+
350
573
  it("routes manual-resolution chart requests through the resolution-aware provider path", async () => {
351
574
  let requested: { range: string; resolution: string } | null = null;
352
575
  const provider = createProvider({
@@ -19,6 +19,8 @@ import {
19
19
  resolveEntryData,
20
20
  toMarketDataContext,
21
21
  } from "../selectors";
22
+ import { resolveTickerFinancialsQuoteState } from "../quotes/resolution";
23
+ import { hasLikelyQuoteUnitMismatch } from "../../utils/currency-units";
22
24
  import { normalizePriceHistory } from "../../utils/price-history";
23
25
  import {
24
26
  createBaselineChartRequest,
@@ -218,6 +220,7 @@ export class MarketDataCoordinator {
218
220
  options,
219
221
  quoteStore: this.quoteStore,
220
222
  runSingleFlight: (key, task) => this.runSingleFlight(key, task),
223
+ resolveQuote: (targetInstrument, quote) => this.resolveIncomingQuote(targetInstrument, quote),
221
224
  });
222
225
  }
223
226
 
@@ -231,6 +234,7 @@ export class MarketDataCoordinator {
231
234
  options,
232
235
  quoteStore: this.quoteStore,
233
236
  runSingleFlight: (key, task) => this.runSingleFlight(key, task),
237
+ resolveQuote: (targetInstrument, quote) => this.resolveIncomingQuote(targetInstrument, quote),
234
238
  });
235
239
  }
236
240
 
@@ -377,10 +381,43 @@ export class MarketDataCoordinator {
377
381
  private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
378
382
  const key = buildQuoteKey(instrument);
379
383
  const current = this.quoteStore.get(key);
380
- if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
384
+ const resolvedQuote = this.resolveIncomingQuote(instrument, quote);
385
+ if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, resolvedQuote)) return;
381
386
  const receivedAt = Date.now();
382
- const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, receivedAt, "success")];
383
- this.quoteStore.set(key, readyQuoteEntry(current, { ...quote, receivedAt }, quote.providerId ?? this.dataProvider.id, attempts));
387
+ const attempts = [createAttempt(resolvedQuote.providerId ?? this.dataProvider.id, receivedAt, "success")];
388
+ this.quoteStore.set(key, readyQuoteEntry(current, { ...resolvedQuote, receivedAt }, resolvedQuote.providerId ?? this.dataProvider.id, attempts));
389
+ }
390
+
391
+ private resolveIncomingQuote(instrument: InstrumentRef, quote: Quote): Quote {
392
+ const snapshot = resolveEntryData(this.snapshotStore.get(buildSnapshotKey(instrument)));
393
+ if (snapshot?.quote) {
394
+ if (hasLikelyQuoteUnitMismatch(snapshot.quote, quote)) return quote;
395
+ if (
396
+ typeof snapshot.quote.lastUpdated === "number"
397
+ && typeof quote.lastUpdated === "number"
398
+ && quote.lastUpdated < snapshot.quote.lastUpdated
399
+ ) {
400
+ return quote;
401
+ }
402
+ }
403
+ const cachedFinancials = snapshot ?? this.readCachedFinancialsForInstrument(instrument);
404
+ return resolveTickerFinancialsQuoteState(cachedFinancials, quote)?.quote ?? quote;
405
+ }
406
+
407
+ private readCachedFinancialsForInstrument(instrument: InstrumentRef): TickerFinancials | null {
408
+ if (!this.dataProvider.getCachedFinancialsForTargets) return null;
409
+ const result = this.dataProvider.getCachedFinancialsForTargets([{
410
+ symbol: instrument.symbol,
411
+ exchange: instrument.exchange,
412
+ brokerId: instrument.brokerId,
413
+ brokerInstanceId: instrument.brokerInstanceId,
414
+ instrument: instrument.instrument ?? null,
415
+ }], {
416
+ allowExpired: true,
417
+ includeStaleQuotes: true,
418
+ });
419
+ if (result instanceof Promise) return null;
420
+ return result.get(instrument.symbol.trim().toUpperCase()) ?? null;
384
421
  }
385
422
  }
386
423
 
@@ -15,7 +15,7 @@ import {
15
15
  readyQuoteEntry,
16
16
  } from "./entries";
17
17
 
18
- export type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "surface" | "visible" | "selected" | "weight">;
18
+ export type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "route" | "surface" | "visible" | "selected" | "weight">;
19
19
 
20
20
  export interface QuoteSubscriptionEntry {
21
21
  target: QuoteSubscriptionTarget;
@@ -123,6 +123,7 @@ interface LoadQuoteEntryOptions {
123
123
  options?: CoordinatorLoadOptions;
124
124
  quoteStore: QueryStore<Quote>;
125
125
  runSingleFlight: CoordinatorSingleFlight;
126
+ resolveQuote?: (instrument: InstrumentRef, quote: Quote) => Quote;
126
127
  }
127
128
 
128
129
  export async function loadQuoteEntry({
@@ -131,6 +132,7 @@ export async function loadQuoteEntry({
131
132
  options = {},
132
133
  quoteStore,
133
134
  runSingleFlight,
135
+ resolveQuote,
134
136
  }: LoadQuoteEntryOptions): Promise<QueryEntry<Quote>> {
135
137
  const key = buildQuoteKey(instrument);
136
138
  const flightKey = options.forceRefresh ? `${key}|refresh` : key;
@@ -146,9 +148,10 @@ export async function loadQuoteEntry({
146
148
  cacheMode: options.forceRefresh ? "refresh" : "default",
147
149
  },
148
150
  );
149
- const source = quote.providerId ?? dataProvider.id;
151
+ const resolvedQuote = resolveQuote?.(instrument, quote) ?? quote;
152
+ const source = resolvedQuote.providerId ?? dataProvider.id;
150
153
  const attempts = [createAttempt(source, startedAt, "success")];
151
- return quoteStore.update(key, (current) => readyQuoteEntry(current, quote, source, attempts));
154
+ return quoteStore.update(key, (current) => readyQuoteEntry(current, resolvedQuote, source, attempts));
152
155
  } catch (error) {
153
156
  const classified = classifyError(error);
154
157
  const attempt = createAttempt(dataProvider.id, startedAt, EXPECTED_EMPTY.test(classified.message) ? "empty" : "fatal_error", classified.reasonCode, classified.message);
@@ -163,6 +166,7 @@ interface LoadQuoteBatchEntriesOptions {
163
166
  options?: CoordinatorLoadOptions;
164
167
  quoteStore: QueryStore<Quote>;
165
168
  runSingleFlight: CoordinatorSingleFlight;
169
+ resolveQuote?: (instrument: InstrumentRef, quote: Quote) => Quote;
166
170
  }
167
171
 
168
172
  export async function loadQuoteBatchEntries({
@@ -171,6 +175,7 @@ export async function loadQuoteBatchEntries({
171
175
  options = {},
172
176
  quoteStore,
173
177
  runSingleFlight,
178
+ resolveQuote,
174
179
  }: LoadQuoteBatchEntriesOptions): Promise<QueryEntry<Quote>[]> {
175
180
  const uniqueInstruments = [...new Map(instruments.map((instrument) => [buildQuoteKey(instrument), instrument] as const)).values()];
176
181
  const results = new Map<string, QueryEntry<Quote>>();
@@ -195,9 +200,10 @@ export async function loadQuoteBatchEntries({
195
200
  const instrument = misses[index];
196
201
  if (!instrument || !item.quote) return;
197
202
  const key = buildQuoteKey(instrument);
198
- const source = item.quote.providerId ?? dataProvider.id;
203
+ const quote = resolveQuote?.(instrument, item.quote) ?? item.quote;
204
+ const source = quote.providerId ?? dataProvider.id;
199
205
  const attempts = [createAttempt(source, Date.now(), "success")];
200
- results.set(key, quoteStore.update(key, (current) => readyQuoteEntry(current, item.quote!, source, attempts)));
206
+ results.set(key, quoteStore.update(key, (current) => readyQuoteEntry(current, quote, source, attempts)));
201
207
  });
202
208
  }
203
209
 
@@ -210,6 +216,7 @@ export async function loadQuoteBatchEntries({
210
216
  options,
211
217
  quoteStore,
212
218
  runSingleFlight,
219
+ resolveQuote,
213
220
  }));
214
221
  }));
215
222
 
@@ -292,6 +299,7 @@ export class QuoteSubscriptionManager {
292
299
  .sort(([left], [right]) => left.localeCompare(right));
293
300
  const nextSignature = activeEntries.map(([key, entry]) => [
294
301
  key,
302
+ entry.target.route ?? "",
295
303
  entry.target.surface ?? "",
296
304
  entry.target.visible ? "visible" : "",
297
305
  entry.target.selected ? "selected" : "",