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
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "r
2
2
  import type { SecFilingDocument, SecFilingItem } from "../types/data-provider";
3
3
  import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
4
4
  import type { TickerRecord } from "../types/ticker";
5
- import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest } from "./request-types";
5
+ import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest, TickerInstrumentOptions } from "./request-types";
6
6
  import { instrumentFromTicker } from "./request-types";
7
7
  import { useAppActive } from "../state/app/activity";
8
8
  import {
@@ -74,17 +74,17 @@ function stableCurrencyList(currencies: Array<string | null | undefined>): strin
74
74
  )].sort((left, right) => left.localeCompare(right));
75
75
  }
76
76
 
77
- function buildTickerFinancialsMapKey(tickers: TickerRecord[]): string {
77
+ function buildTickerFinancialsMapKey(tickers: TickerRecord[], options: TickerInstrumentOptions = {}): string {
78
78
  return tickers.map((ticker) => {
79
- const instrument = ticker.metadata.broker_contracts?.[0];
79
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
80
80
  return [
81
81
  ticker.metadata.ticker,
82
82
  ticker.metadata.exchange ?? "",
83
83
  instrument?.brokerId ?? "",
84
84
  instrument?.brokerInstanceId ?? "",
85
- instrument?.conId ?? "",
86
- instrument?.localSymbol ?? "",
87
- instrument?.symbol ?? "",
85
+ instrument?.instrument?.conId ?? "",
86
+ instrument?.instrument?.localSymbol ?? "",
87
+ instrument?.instrument?.symbol ?? "",
88
88
  ].join("|");
89
89
  }).join("::");
90
90
  }
@@ -122,10 +122,10 @@ export function useTickerFinancials(symbol: string | null | undefined, ticker: T
122
122
  return financials;
123
123
  }
124
124
 
125
- function buildTickerFinancialsKeys(tickers: TickerRecord[]): string[] {
125
+ function buildTickerFinancialsKeys(tickers: TickerRecord[], options: TickerInstrumentOptions = {}): string[] {
126
126
  const keys: string[] = [];
127
127
  for (const ticker of tickers) {
128
- const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
128
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
129
129
  if (!instrument) continue;
130
130
  keys.push(
131
131
  buildSnapshotKey(instrument),
@@ -136,17 +136,21 @@ function buildTickerFinancialsKeys(tickers: TickerRecord[]): string[] {
136
136
  return keys;
137
137
  }
138
138
 
139
- export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, TickerFinancials> {
139
+ export function useTickerFinancialsMap(
140
+ tickers: TickerRecord[],
141
+ options: TickerInstrumentOptions = {},
142
+ ): Map<string, TickerFinancials> {
140
143
  const coordinator = getSharedMarketDataCoordinator();
141
- const tickerKey = buildTickerFinancialsMapKey(tickers);
142
- const subscriptionKeys = useMemo(() => buildTickerFinancialsKeys(tickers), [tickerKey]);
144
+ const optionsKey = options.portfolioId ?? "";
145
+ const tickerKey = buildTickerFinancialsMapKey(tickers, options);
146
+ const subscriptionKeys = useMemo(() => buildTickerFinancialsKeys(tickers, options), [optionsKey, tickerKey]);
143
147
  const keysVersion = useCoordinatorKeysVersion(subscriptionKeys);
144
148
 
145
149
  return useMemo(() => {
146
150
  if (!coordinator) return new Map<string, TickerFinancials>();
147
151
  const result = new Map<string, TickerFinancials>();
148
152
  for (const ticker of tickers) {
149
- const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
153
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
150
154
  if (!instrument) continue;
151
155
  const financials = coordinator.getTickerFinancialsSync(instrument);
152
156
  if (financials) {
@@ -154,7 +158,7 @@ export function useTickerFinancialsMap(tickers: TickerRecord[]): Map<string, Tic
154
158
  }
155
159
  }
156
160
  return result;
157
- }, [coordinator, keysVersion, tickerKey, subscriptionKeys]);
161
+ }, [coordinator, keysVersion, optionsKey, tickerKey, subscriptionKeys]);
158
162
  }
159
163
 
160
164
  export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): QueryEntry<Quote> | null {
@@ -5,7 +5,33 @@ const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "
5
5
  const ALWAYS_OPEN_EXCHANGES = new Set(["CCC"]);
6
6
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
7
7
  const OVERNIGHT_CLOSE_MAX_AGE_MS = 20 * 60 * 60 * 1000;
8
+ const REGULAR_OPEN_MINUTES: Record<string, number> = {
9
+ NASDAQ: 9 * 60 + 30,
10
+ NYSE: 9 * 60 + 30,
11
+ ARCA: 9 * 60 + 30,
12
+ AMEX: 9 * 60 + 30,
13
+ BATS: 9 * 60 + 30,
14
+ TSX: 9 * 60 + 30,
15
+ TSXV: 9 * 60 + 30,
16
+ CSE: 9 * 60 + 30,
17
+ FWB2: 8 * 60,
18
+ XETRA: 8 * 60,
19
+ LSE: 8 * 60,
20
+ EPA: 9 * 60,
21
+ AMS: 9 * 60,
22
+ BRU: 9 * 60,
23
+ LIS: 8 * 60,
24
+ BIT: 9 * 60,
25
+ SFB: 9 * 60,
26
+ HEL: 10 * 60,
27
+ CPH: 9 * 60,
28
+ JPX: 9 * 60,
29
+ HKEX: 9 * 60 + 30,
30
+ TWSE: 9 * 60,
31
+ NSE: 9 * 60 + 15,
32
+ };
8
33
  const exchangeLocalDateFormatters = new Map<string, Intl.DateTimeFormat>();
34
+ const exchangeLocalTimeFormatters = new Map<string, Intl.DateTimeFormat>();
9
35
  const usSessionFormatter = new Intl.DateTimeFormat("en-US", {
10
36
  timeZone: "America/New_York",
11
37
  weekday: "short",
@@ -34,6 +60,20 @@ function getExchangeLocalDateFormatter(timeZone: string): Intl.DateTimeFormat {
34
60
  return formatter;
35
61
  }
36
62
 
63
+ function getExchangeLocalTimeFormatter(timeZone: string): Intl.DateTimeFormat {
64
+ let formatter = exchangeLocalTimeFormatters.get(timeZone);
65
+ if (!formatter) {
66
+ formatter = new Intl.DateTimeFormat("en-US", {
67
+ timeZone,
68
+ hour: "2-digit",
69
+ minute: "2-digit",
70
+ hourCycle: "h23",
71
+ });
72
+ exchangeLocalTimeFormatters.set(timeZone, formatter);
73
+ }
74
+ return formatter;
75
+ }
76
+
37
77
  function exchangeLocalDate(exchange: string, timestampMs: number): string | null {
38
78
  const timeZone = EXCHANGE_TIME_ZONES[canonicalExchange(exchange)];
39
79
  if (!timeZone) return null;
@@ -46,6 +86,23 @@ function exchangeLocalDate(exchange: string, timestampMs: number): string | null
46
86
  return `${year}-${month}-${day}`;
47
87
  }
48
88
 
89
+ function exchangeLocalMinuteOfDay(exchange: string, timestampMs: number): number | null {
90
+ const timeZone = EXCHANGE_TIME_ZONES[canonicalExchange(exchange)];
91
+ if (!timeZone) return null;
92
+
93
+ const parts = getExchangeLocalTimeFormatter(timeZone).formatToParts(new Date(timestampMs));
94
+ const hour = Number(parts.find((part) => part.type === "hour")?.value);
95
+ const minute = Number(parts.find((part) => part.type === "minute")?.value);
96
+ return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : null;
97
+ }
98
+
99
+ function isBeforeKnownRegularOpen(exchange: string, timestampMs: number): boolean {
100
+ const canonical = canonicalExchange(exchange);
101
+ const openMinute = REGULAR_OPEN_MINUTES[canonical];
102
+ const localMinute = exchangeLocalMinuteOfDay(canonical, timestampMs);
103
+ return openMinute != null && localMinute != null && localMinute < openMinute;
104
+ }
105
+
49
106
  function isoLocalDateToUtcDay(date: string): number | null {
50
107
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
51
108
  if (!match) return null;
@@ -122,7 +179,7 @@ function isTimestampStaleForExchangeSessionUnsafe(
122
179
  if (!timestampDate || !currentDate || timestampDate === currentDate) return false;
123
180
 
124
181
  if (ALWAYS_OPEN_EXCHANGES.has(canonical)) return true;
125
- if (marketState === "REGULAR") return true;
182
+ if (marketState === "REGULAR" && !isBeforeKnownRegularOpen(canonical, now)) return true;
126
183
 
127
184
  if (isUsExtendedHoursExchange(canonical)) {
128
185
  const session = usSessionState(now);
@@ -132,7 +132,55 @@ describe("quote freshness", () => {
132
132
  ).toBe(false);
133
133
  });
134
134
 
135
- test("treats prior-date regular-session quotes as stale immediately", () => {
135
+ test("allows prior-day regular-session quotes before the local exchange reopens", () => {
136
+ expect(
137
+ isQuoteStaleForCurrentSession(
138
+ quote({
139
+ symbol: "NEO.TO",
140
+ currency: "CAD",
141
+ lastUpdated: Date.parse("2026-07-06T20:00:00Z"),
142
+ listingExchangeName: "TOR",
143
+ exchangeName: "TOR",
144
+ marketState: "REGULAR",
145
+ }),
146
+ Date.parse("2026-07-07T12:10:00Z"),
147
+ ),
148
+ ).toBe(false);
149
+ });
150
+
151
+ test("allows non-US premarket labels without active-session prices", () => {
152
+ expect(
153
+ isQuoteStaleForCurrentSession(
154
+ quote({
155
+ symbol: "NEO.TO",
156
+ currency: "CAD",
157
+ lastUpdated: Date.parse("2026-07-07T12:10:00Z"),
158
+ listingExchangeName: "TOR",
159
+ exchangeName: "TOR",
160
+ marketState: "PRE",
161
+ }),
162
+ Date.parse("2026-07-07T12:10:30Z"),
163
+ ),
164
+ ).toBe(false);
165
+ });
166
+
167
+ test("rejects US premarket labels without active-session prices", () => {
168
+ expect(
169
+ isQuoteStaleForCurrentSession(
170
+ quote({
171
+ symbol: "ALAB",
172
+ currency: "USD",
173
+ lastUpdated: Date.parse("2026-07-07T12:10:00Z"),
174
+ listingExchangeName: "NASDAQ",
175
+ exchangeName: "NASDAQ",
176
+ marketState: "PRE",
177
+ }),
178
+ Date.parse("2026-07-07T12:10:30Z"),
179
+ ),
180
+ ).toBe(true);
181
+ });
182
+
183
+ test("treats prior-date regular-session quotes as stale after the local exchange reopens", () => {
136
184
  expect(
137
185
  isQuoteStaleForCurrentSession(
138
186
  quote({
@@ -1,7 +1,15 @@
1
1
  import type { Quote } from "../../types/financials";
2
+ import { canonicalExchange } from "../../utils/exchanges";
2
3
  import { isTimestampStaleForExchangeSession } from "../market/freshness";
3
4
 
5
+ const EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "BATS"]);
6
+
7
+ function isExtendedHoursExchange(quote: Quote): boolean {
8
+ return EXTENDED_HOURS_EXCHANGES.has(canonicalExchange(quote.listingExchangeName || quote.exchangeName));
9
+ }
10
+
4
11
  function isQuoteMissingActiveSessionPrice(quote: Quote): boolean {
12
+ if (!isExtendedHoursExchange(quote)) return false;
5
13
  if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice == null) {
6
14
  return true;
7
15
  }
@@ -68,6 +68,191 @@ describe("quote-resolution", () => {
68
68
  expect(quote?.provenance?.session?.providerId).toBe("yahoo");
69
69
  });
70
70
 
71
+ test("uses provider previous close with the live broker price for daily change", () => {
72
+ const now = Date.parse("2026-07-06T14:05:00Z");
73
+ const contributions: QuoteContributionMap = {
74
+ ibkr: {
75
+ symbol: "VICR",
76
+ providerId: "ibkr",
77
+ dataSource: "live",
78
+ price: 299.8,
79
+ currency: "USD",
80
+ change: -80.3,
81
+ changePercent: -21.12,
82
+ previousClose: 380.1,
83
+ lastUpdated: Date.parse("2026-07-06T14:04:58Z"),
84
+ receivedAt: Date.parse("2026-07-06T14:04:59Z"),
85
+ listingExchangeName: "NASDAQ",
86
+ routingExchangeName: "SMART",
87
+ sessionConfidence: "unknown",
88
+ },
89
+ yahoo: {
90
+ symbol: "VICR",
91
+ providerId: "yahoo",
92
+ dataSource: "delayed",
93
+ price: 299.74,
94
+ currency: "USD",
95
+ change: 16.79,
96
+ changePercent: 5.93,
97
+ previousClose: 282.95,
98
+ lastUpdated: Date.parse("2026-07-06T14:04:00Z"),
99
+ listingExchangeName: "NMS",
100
+ marketState: "REGULAR",
101
+ sessionConfidence: "derived",
102
+ },
103
+ };
104
+
105
+ const quote = resolveCanonicalQuote(contributions, now).quote;
106
+
107
+ expect(quote?.providerId).toBe("ibkr");
108
+ expect(quote?.price).toBe(299.8);
109
+ expect(quote?.previousClose).toBe(282.95);
110
+ expect(quote?.change).toBeCloseTo(16.85, 10);
111
+ expect(quote?.changePercent).toBeCloseTo((16.85 / 282.95) * 100, 10);
112
+ expect(quote?.provenance?.price?.providerId).toBe("ibkr");
113
+ expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
114
+ });
115
+
116
+ test("prefers fresher/provider-ranked non-live price candidates", () => {
117
+ const now = Date.parse("2026-07-07T11:25:00Z");
118
+ const cases: Array<{
119
+ contributions: QuoteContributionMap;
120
+ expectedPrice: number;
121
+ expectedProvider: string;
122
+ expectedChangePercent?: number;
123
+ expectedRoute?: string;
124
+ }> = [
125
+ {
126
+ contributions: {
127
+ ibkr: {
128
+ symbol: "LPK",
129
+ providerId: "ibkr",
130
+ dataSource: "delayed",
131
+ price: 17.75,
132
+ currency: "EUR",
133
+ change: -1.85,
134
+ changePercent: -9.44,
135
+ previousClose: 19.6,
136
+ lastUpdated: Date.parse("2026-07-07T11:02:00Z"),
137
+ receivedAt: Date.parse("2026-07-07T11:02:01Z"),
138
+ listingExchangeName: "IBIS",
139
+ routingExchangeName: "SMART",
140
+ sessionConfidence: "unknown",
141
+ },
142
+ yahoo: {
143
+ symbol: "LPK.DE",
144
+ providerId: "yahoo",
145
+ dataSource: "delayed",
146
+ price: 17.85,
147
+ currency: "EUR",
148
+ change: -1.75,
149
+ changePercent: -8.93,
150
+ previousClose: 19.6,
151
+ lastUpdated: Date.parse("2026-07-07T11:24:00Z"),
152
+ listingExchangeName: "GER",
153
+ marketState: "REGULAR",
154
+ sessionConfidence: "derived",
155
+ },
156
+ },
157
+ expectedProvider: "yahoo",
158
+ expectedPrice: 17.85,
159
+ expectedChangePercent: ((17.85 - 19.6) / 19.6) * 100,
160
+ expectedRoute: "SMART",
161
+ },
162
+ {
163
+ contributions: {
164
+ "gloomberb-cloud": {
165
+ symbol: "3HNX",
166
+ providerId: "gloomberb-cloud",
167
+ dataSource: "delayed",
168
+ price: 5.81,
169
+ currency: "GBP",
170
+ change: -1.58,
171
+ changePercent: -21.42,
172
+ previousClose: 7.39,
173
+ lastUpdated: Date.parse("2026-07-07T11:18:00Z"),
174
+ listingExchangeName: "LSE",
175
+ marketState: "REGULAR",
176
+ sessionConfidence: "derived",
177
+ },
178
+ yahoo: {
179
+ symbol: "3HNX.L",
180
+ providerId: "yahoo",
181
+ dataSource: "delayed",
182
+ price: 5.9775,
183
+ currency: "GBP",
184
+ change: -1.4125,
185
+ changePercent: -19.09,
186
+ previousClose: 7.39,
187
+ lastUpdated: Date.parse("2026-07-07T11:24:00Z"),
188
+ listingExchangeName: "LSE",
189
+ marketState: "REGULAR",
190
+ sessionConfidence: "derived",
191
+ },
192
+ },
193
+ expectedProvider: "yahoo",
194
+ expectedPrice: 5.9775,
195
+ },
196
+ ];
197
+
198
+ for (const scenario of cases) {
199
+ const quote = resolveCanonicalQuote(scenario.contributions, now).quote;
200
+ expect(quote?.providerId).toBe(scenario.expectedProvider);
201
+ expect(quote?.price).toBe(scenario.expectedPrice);
202
+ expect(quote?.provenance?.price?.providerId).toBe(scenario.expectedProvider);
203
+ if (scenario.expectedChangePercent != null) {
204
+ expect(quote?.changePercent).toBeCloseTo(scenario.expectedChangePercent, 10);
205
+ }
206
+ if (scenario.expectedRoute) {
207
+ expect(quote?.routingExchangeName).toBe(scenario.expectedRoute);
208
+ }
209
+ }
210
+ });
211
+
212
+ test("prefers yahoo day reference fields over stale cloud previous close data", () => {
213
+ const now = Date.parse("2026-07-06T15:45:00Z");
214
+ const contributions: QuoteContributionMap = {
215
+ "gloomberb-cloud": {
216
+ symbol: "VICR",
217
+ providerId: "gloomberb-cloud",
218
+ dataSource: "delayed",
219
+ price: 299.8,
220
+ currency: "USD",
221
+ change: -80.27,
222
+ changePercent: -21.12,
223
+ previousClose: 380.07,
224
+ lastUpdated: Date.parse("2026-07-06T15:44:00Z"),
225
+ listingExchangeName: "NASDAQ",
226
+ marketState: "REGULAR",
227
+ sessionConfidence: "derived",
228
+ },
229
+ yahoo: {
230
+ symbol: "VICR",
231
+ providerId: "yahoo",
232
+ dataSource: "delayed",
233
+ price: 297.9,
234
+ currency: "USD",
235
+ change: 14.95,
236
+ changePercent: 5.28,
237
+ previousClose: 282.95,
238
+ lastUpdated: Date.parse("2026-07-06T15:41:00Z"),
239
+ listingExchangeName: "NMS",
240
+ marketState: "REGULAR",
241
+ sessionConfidence: "derived",
242
+ },
243
+ };
244
+
245
+ const quote = resolveCanonicalQuote(contributions, now).quote;
246
+
247
+ expect(quote?.providerId).toBe("gloomberb-cloud");
248
+ expect(quote?.price).toBe(299.8);
249
+ expect(quote?.previousClose).toBe(282.95);
250
+ expect(quote?.change).toBeCloseTo(16.85, 10);
251
+ expect(quote?.changePercent).toBeCloseTo((16.85 / 282.95) * 100, 10);
252
+ expect(quote?.provenance?.price?.providerId).toBe("gloomberb-cloud");
253
+ expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
254
+ });
255
+
71
256
  test("prefers cloud session data over yahoo when confidence is tied", () => {
72
257
  const now = Date.parse("2026-04-08T11:00:00Z");
73
258
  const contributions: QuoteContributionMap = {
@@ -31,9 +31,6 @@ const PRICE_FIELD_KEYS = [
31
31
  "symbol",
32
32
  "price",
33
33
  "currency",
34
- "change",
35
- "changePercent",
36
- "previousClose",
37
34
  "bid",
38
35
  "ask",
39
36
  "bidSize",
@@ -88,12 +85,39 @@ function providerKindRank(providerId?: string): number {
88
85
  }
89
86
  }
90
87
 
88
+ function dailyReferenceRank(providerId?: string): number {
89
+ switch (providerId) {
90
+ case "yahoo":
91
+ return 0;
92
+ case "gloomberb-cloud":
93
+ return 1;
94
+ case "ibkr":
95
+ return 2;
96
+ default:
97
+ return 3;
98
+ }
99
+ }
100
+
91
101
  function priceRank(quote: QuoteContribution): number {
92
102
  if (quote.providerId === "ibkr" && quote.dataSource === "live") return 0;
93
- if (quote.providerId === "ibkr") return 1;
94
- if (quote.providerId === "gloomberb-cloud") return 2;
95
- if (quote.providerId === "yahoo") return 3;
96
- return 4;
103
+ return 1;
104
+ }
105
+
106
+ function priceProviderTieRank(providerId?: string): number {
107
+ switch (providerId) {
108
+ case "gloomberb-cloud":
109
+ return 0;
110
+ case "yahoo":
111
+ return 1;
112
+ case "ibkr":
113
+ return 2;
114
+ default:
115
+ return 3;
116
+ }
117
+ }
118
+
119
+ function quoteUpdateTime(quote: QuoteContribution): number {
120
+ return quote.lastUpdated ?? quote.receivedAt ?? 0;
97
121
  }
98
122
 
99
123
  function sessionConfidenceRank(confidence?: SessionConfidence): number {
@@ -144,6 +168,61 @@ function pickField(
144
168
  return undefined;
145
169
  }
146
170
 
171
+ function finitePositiveNumber(value: unknown): value is number {
172
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
173
+ }
174
+
175
+ function buildDailyReferenceCandidates(
176
+ candidates: QuoteContribution[],
177
+ priceProvider: QuoteContribution | undefined,
178
+ ): QuoteContribution[] {
179
+ return [...candidates]
180
+ .filter((quote) => finitePositiveNumber(quote.previousClose))
181
+ .sort((left, right) => {
182
+ if (priceProvider) {
183
+ const leftMismatch = hasLikelyQuoteUnitMismatch(priceProvider, left) ? 1 : 0;
184
+ const rightMismatch = hasLikelyQuoteUnitMismatch(priceProvider, right) ? 1 : 0;
185
+ if (leftMismatch !== rightMismatch) return leftMismatch - rightMismatch;
186
+ }
187
+ const providerDelta = dailyReferenceRank(left.providerId) - dailyReferenceRank(right.providerId);
188
+ if (providerDelta !== 0) return providerDelta;
189
+ return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
190
+ });
191
+ }
192
+
193
+ function assignDailyChangeFields(
194
+ target: Partial<Quote>,
195
+ provenance: QuoteProvenance,
196
+ priceProvider: QuoteContribution | undefined,
197
+ candidates: QuoteContribution[],
198
+ ): void {
199
+ const referenceProvider = buildDailyReferenceCandidates(candidates, priceProvider)[0];
200
+ if (!referenceProvider) {
201
+ pickField(target, provenance, "previousClose", candidates);
202
+ pickField(target, provenance, "change", candidates);
203
+ pickField(target, provenance, "changePercent", candidates);
204
+ return;
205
+ }
206
+
207
+ assignField(target, provenance, "previousClose", referenceProvider);
208
+ const price = target.price;
209
+ const previousClose = target.previousClose;
210
+ if (!finitePositiveNumber(price) || !finitePositiveNumber(previousClose)) {
211
+ pickField(target, provenance, "change", candidates);
212
+ pickField(target, provenance, "changePercent", candidates);
213
+ return;
214
+ }
215
+
216
+ target.change = price - previousClose;
217
+ target.changePercent = (target.change / previousClose) * 100;
218
+ provenance.fields ??= {};
219
+ const referenceProvenance = toProvenance(referenceProvider);
220
+ if (referenceProvenance) {
221
+ provenance.fields.change = referenceProvenance;
222
+ provenance.fields.changePercent = referenceProvenance;
223
+ }
224
+ }
225
+
147
226
  function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
148
227
  accepted: QuoteContribution[];
149
228
  rejectedProviders: string[];
@@ -153,7 +232,13 @@ function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
153
232
  .sort((left, right) => {
154
233
  const rankDelta = priceRank(left) - priceRank(right);
155
234
  if (rankDelta !== 0) return rankDelta;
156
- return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
235
+ if (priceRank(left) > 0) {
236
+ const updateDelta = quoteUpdateTime(right) - quoteUpdateTime(left);
237
+ if (updateDelta !== 0) return updateDelta;
238
+ }
239
+ const providerDelta = priceProviderTieRank(left.providerId) - priceProviderTieRank(right.providerId);
240
+ if (providerDelta !== 0) return providerDelta;
241
+ return quoteUpdateTime(right) - quoteUpdateTime(left);
157
242
  });
158
243
 
159
244
  const accepted: QuoteContribution[] = [];
@@ -290,6 +375,7 @@ export function resolveCanonicalQuote(
290
375
  if (field === "price") continue;
291
376
  pickField(resolved, provenance, field, acceptedPriceCandidates);
292
377
  }
378
+ assignDailyChangeFields(resolved, provenance, priceProvider, acceptedPriceCandidates);
293
379
  provenance.price = toProvenance(priceProvider);
294
380
 
295
381
  const sessionProvider = sessionCandidates[0];
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { TickerRecord } from "../types/ticker";
3
- import { quoteSubscriptionTargetFromTicker } from "./request-types";
3
+ import { instrumentFromTicker, quoteSubscriptionTargetFromTicker } from "./request-types";
4
4
 
5
5
  function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRecord {
6
6
  return {
@@ -21,6 +21,66 @@ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRe
21
21
  }
22
22
 
23
23
  describe("quoteSubscriptionTargetFromTicker", () => {
24
+ test("selects the broker contract that belongs to the active portfolio", () => {
25
+ const ticker = makeTicker({
26
+ ticker: "VICR",
27
+ name: "Vicor",
28
+ portfolios: [
29
+ "broker:ibkr-live:DU111",
30
+ "broker:ibkr-coldstart:DU222",
31
+ ],
32
+ positions: [
33
+ {
34
+ portfolio: "broker:ibkr-live:DU111",
35
+ shares: 170,
36
+ avgCost: 198,
37
+ currency: "USD",
38
+ broker: "ibkr",
39
+ brokerInstanceId: "ibkr-live",
40
+ brokerContractId: 275759,
41
+ },
42
+ {
43
+ portfolio: "broker:ibkr-coldstart:DU222",
44
+ shares: 350,
45
+ avgCost: 290,
46
+ currency: "USD",
47
+ broker: "ibkr",
48
+ brokerInstanceId: "ibkr-coldstart",
49
+ brokerContractId: 275759,
50
+ },
51
+ ],
52
+ broker_contracts: [
53
+ {
54
+ brokerId: "ibkr",
55
+ brokerInstanceId: "ibkr-live",
56
+ conId: 275759,
57
+ symbol: "VICR",
58
+ localSymbol: "VICR",
59
+ exchange: "NASDAQ",
60
+ currency: "USD",
61
+ secType: "STK",
62
+ },
63
+ {
64
+ brokerId: "ibkr",
65
+ brokerInstanceId: "ibkr-coldstart",
66
+ conId: 275759,
67
+ symbol: "VICR",
68
+ localSymbol: "VICR",
69
+ exchange: "NASDAQ",
70
+ currency: "USD",
71
+ secType: "STK",
72
+ },
73
+ ],
74
+ });
75
+
76
+ expect(instrumentFromTicker(ticker, "VICR", { portfolioId: "broker:ibkr-coldstart:DU222" })).toMatchObject({
77
+ symbol: "VICR",
78
+ brokerId: "ibkr",
79
+ brokerInstanceId: "ibkr-coldstart",
80
+ instrument: ticker.metadata.broker_contracts?.[1],
81
+ });
82
+ });
83
+
24
84
  test("preserves broker contract context for streaming targets", () => {
25
85
  const ticker = makeTicker({
26
86
  broker_contracts: [{