gloomberb 0.6.1 → 0.7.1

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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -195,6 +195,128 @@ describe("AssetDataRouter", () => {
195
195
  expect(holders.holders[0]?.name).toBe("Vanguard Group Inc");
196
196
  });
197
197
 
198
+ test("falls back to later analyst providers when rating targets are missing", async () => {
199
+ const cloudProvider: DataProvider = {
200
+ ...fallbackProvider,
201
+ id: "cloud",
202
+ name: "Cloud",
203
+ priority: 100,
204
+ async getAnalystResearch(symbol) {
205
+ return {
206
+ providerId: "cloud",
207
+ symbol,
208
+ ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
209
+ recommendations: [],
210
+ earningsEstimates: [],
211
+ revenueEstimates: [],
212
+ };
213
+ },
214
+ };
215
+ const yahooProvider: DataProvider = {
216
+ ...fallbackProvider,
217
+ id: "yahoo",
218
+ name: "Yahoo",
219
+ priority: 1000,
220
+ async getAnalystResearch(symbol) {
221
+ return {
222
+ providerId: "yahoo",
223
+ symbol,
224
+ ratings: [{
225
+ date: "2026-05-01",
226
+ firm: "Yahoo Firm",
227
+ action: "Raises",
228
+ current: "Buy",
229
+ prior: "Buy",
230
+ currentPriceTarget: 680,
231
+ priorPriceTarget: 595,
232
+ }],
233
+ recommendations: [],
234
+ earningsEstimates: [],
235
+ revenueEstimates: [],
236
+ };
237
+ },
238
+ };
239
+
240
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
241
+ const research = await router.getAnalystResearch("AAPL", "NASDAQ");
242
+
243
+ expect(research.providerId).toBe("yahoo");
244
+ expect(research.ratings[0]?.currentPriceTarget).toBe(680);
245
+ expect(research.ratings[0]?.priorPriceTarget).toBe(595);
246
+ });
247
+
248
+ test("prefers cached analyst records with rating targets", async () => {
249
+ const dbPath = createTempDbPath("cached-analyst-targets");
250
+ const persistence = new AppPersistence(dbPath);
251
+ const cloudProvider: DataProvider = {
252
+ ...fallbackProvider,
253
+ id: "cloud",
254
+ name: "Cloud",
255
+ priority: 100,
256
+ async getAnalystResearch(symbol) {
257
+ return {
258
+ providerId: "cloud",
259
+ symbol,
260
+ ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
261
+ recommendations: [],
262
+ earningsEstimates: [],
263
+ revenueEstimates: [],
264
+ };
265
+ },
266
+ };
267
+ const yahooProvider: DataProvider = {
268
+ ...fallbackProvider,
269
+ id: "yahoo",
270
+ name: "Yahoo",
271
+ priority: 1000,
272
+ async getAnalystResearch(symbol) {
273
+ return {
274
+ providerId: "yahoo",
275
+ symbol,
276
+ ratings: [{
277
+ date: "2026-05-01",
278
+ firm: "Yahoo Firm",
279
+ action: "Raises",
280
+ current: "Buy",
281
+ prior: "Buy",
282
+ currentPriceTarget: 680,
283
+ priorPriceTarget: 595,
284
+ }],
285
+ recommendations: [],
286
+ earningsEstimates: [],
287
+ revenueEstimates: [],
288
+ };
289
+ },
290
+ };
291
+ const unavailableProvider = (id: string, priority: number): DataProvider => ({
292
+ ...fallbackProvider,
293
+ id,
294
+ name: id,
295
+ priority,
296
+ async getAnalystResearch() {
297
+ throw new Error(`${id} should not be fetched`);
298
+ },
299
+ });
300
+
301
+ try {
302
+ const seedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources);
303
+ await seedRouter.getAnalystResearch("AAPL", "NASDAQ");
304
+
305
+ const cachedRouter = new AssetDataRouter(
306
+ unavailableProvider("yahoo", 1000),
307
+ [unavailableProvider("cloud", 100)],
308
+ persistence.resources,
309
+ );
310
+ const research = await cachedRouter.getAnalystResearch("AAPL", "NASDAQ");
311
+
312
+ expect(research.providerId).toBe("yahoo");
313
+ expect(research.ratings[0]?.currentPriceTarget).toBe(680);
314
+ expect(research.ratings[0]?.priorPriceTarget).toBe(595);
315
+ } finally {
316
+ persistence.close();
317
+ }
318
+ });
319
+
198
320
  test("prefers broker quotes over fallback quotes", async () => {
199
321
  const router = new AssetDataRouter(fallbackProvider);
200
322
  const broker: BrokerAdapter = {
@@ -8,9 +8,11 @@ import type {
8
8
  CachedFinancialsTarget,
9
9
  DataProvider,
10
10
  MarketDataRequestContext,
11
+ QuoteBatchResult,
11
12
  QuoteSubscriptionTarget,
12
13
  SearchRequestContext,
13
14
  SecFilingItem,
15
+ TickerFinancialsBatchResult,
14
16
  } from "../types/data-provider";
15
17
  import type { CapabilityRouteSource } from "../types/capability-route-source";
16
18
  import { routeSourcePriority } from "../types/capability-route-source";
@@ -134,10 +136,6 @@ function isIntradayRange(range: TimeRange): boolean {
134
136
  return range === "1D" || range === "1W" || range === "1M" || range === "3M";
135
137
  }
136
138
 
137
- function hasMeaningfulFundamentals(data: TickerFinancials | null | undefined): boolean {
138
- return !!data && Object.keys(data.fundamentals ?? {}).length > 0;
139
- }
140
-
141
139
  function hasMeaningfulProfile(data: TickerFinancials | null | undefined): boolean {
142
140
  return !!data && !!(
143
141
  data.profile?.description
@@ -154,6 +152,16 @@ function hasAnalystResearchValue(data: AnalystResearchData): boolean {
154
152
  || data.revenueEstimates.length > 0;
155
153
  }
156
154
 
155
+ function hasAnalystRatingPriceTargets(data: AnalystResearchData): boolean {
156
+ return data.ratings.some((rating) => (
157
+ rating.currentPriceTarget != null || rating.priorPriceTarget != null
158
+ ));
159
+ }
160
+
161
+ function isAnalystResearchMissingRatingTargets(data: AnalystResearchData): boolean {
162
+ return data.ratings.length > 0 && !hasAnalystRatingPriceTargets(data);
163
+ }
164
+
157
165
  function hasCorporateActionsValue(data: CorporateActionsData): boolean {
158
166
  return data.dividends.length > 0
159
167
  || data.splits.length > 0
@@ -349,6 +357,135 @@ export class AssetDataRouter implements DataProvider {
349
357
  return results;
350
358
  }
351
359
 
360
+ async getQuotesBatch(
361
+ targets: QuoteSubscriptionTarget[],
362
+ options: { forceRefresh?: boolean } = {},
363
+ ): Promise<QuoteBatchResult[]> {
364
+ const forceRefresh = options.forceRefresh === true;
365
+ const results = new Array<QuoteBatchResult | null>(targets.length).fill(null);
366
+ const misses: Array<{ index: number; target: QuoteSubscriptionTarget }> = [];
367
+
368
+ targets.forEach((target, index) => {
369
+ const context = target.context;
370
+ const entityKey = this.getEntityKey(target.symbol, target.exchange, context?.instrument);
371
+ const variantKeys = this.getTickerVariantCandidates(target.exchange);
372
+ const sourceKeys = [
373
+ ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
374
+ ...this.getProviderSourceKeys(),
375
+ ];
376
+ const rawCached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
377
+ const cached = rawCached && !isQuoteStaleForCurrentSession(rawCached.value)
378
+ ? rawCached
379
+ : null;
380
+ if (cached && !forceRefresh && !cached.stale) {
381
+ results[index] = { target, quote: cached.value };
382
+ return;
383
+ }
384
+ misses.push({ index, target });
385
+ });
386
+
387
+ const batchProvider = this.providersInPriorityOrder().find((provider) => provider.getQuotesBatch);
388
+ const providerMisses = misses.filter(({ target }) => !this.hasBrokerContext(target.context));
389
+ const providerIndexes = new Map<string, Array<{ index: number; target: QuoteSubscriptionTarget }>>();
390
+ if (batchProvider && providerMisses.length > 0) {
391
+ for (const entry of providerMisses) {
392
+ const key = this.quoteBatchKey(entry.target);
393
+ const bucket = providerIndexes.get(key) ?? [];
394
+ bucket.push(entry);
395
+ providerIndexes.set(key, bucket);
396
+ }
397
+ const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
398
+ const batchResults = await batchProvider.getQuotesBatch!(uniqueTargets, options).catch(() => []);
399
+ for (const item of batchResults) {
400
+ if (!item.quote) continue;
401
+ const key = this.quoteBatchKey(item.target);
402
+ const sourceKey = this.providerSourceKey(batchProvider);
403
+ for (const entry of providerIndexes.get(key) ?? []) {
404
+ const entityKey = this.getEntityKey(entry.target.symbol, entry.target.exchange, entry.target.context?.instrument);
405
+ const variantKey = this.getTickerVariantCandidates(entry.target.exchange)[0] ?? "";
406
+ this.cacheResource("quote", entityKey, variantKey, sourceKey, item.quote, this.resolveProviderPolicy("quote", batchProvider));
407
+ results[entry.index] = { target: entry.target, quote: item.quote };
408
+ }
409
+ }
410
+ }
411
+
412
+ await Promise.all(misses.map(async ({ index, target }) => {
413
+ if (results[index]) return;
414
+ try {
415
+ const quote = await this.getQuote(target.symbol, target.exchange, {
416
+ ...target.context,
417
+ cacheMode: forceRefresh ? "refresh" : "default",
418
+ });
419
+ results[index] = { target, quote };
420
+ } catch (error) {
421
+ results[index] = { target, quote: null, error };
422
+ }
423
+ }));
424
+
425
+ return results.map((result, index) => result ?? { target: targets[index]!, quote: null });
426
+ }
427
+
428
+ async getTickerFinancialsBatch(
429
+ targets: CachedFinancialsTarget[],
430
+ options: { forceRefresh?: boolean } = {},
431
+ ): Promise<TickerFinancialsBatchResult[]> {
432
+ const forceRefresh = options.forceRefresh === true;
433
+ const results = new Array<TickerFinancialsBatchResult | null>(targets.length).fill(null);
434
+ const misses: Array<{ index: number; target: CachedFinancialsTarget }> = [];
435
+
436
+ targets.forEach((target, index) => {
437
+ const context = this.contextFromCachedTarget(target);
438
+ const cached = this.readCachedMergedFinancialsSelection(target.symbol, target.exchange, context, true);
439
+ if (cached.value && !forceRefresh) {
440
+ results[index] = { target, financials: cached.value };
441
+ return;
442
+ }
443
+ misses.push({ index, target });
444
+ });
445
+
446
+ const batchProvider = this.providersInPriorityOrder().find((provider) => provider.getTickerFinancialsBatch);
447
+ const providerMisses = misses.filter(({ target }) => !this.hasCachedTargetBrokerContext(target));
448
+ const providerIndexes = new Map<string, Array<{ index: number; target: CachedFinancialsTarget }>>();
449
+ if (batchProvider && providerMisses.length > 0) {
450
+ for (const entry of providerMisses) {
451
+ const key = this.cachedFinancialsBatchKey(entry.target);
452
+ const bucket = providerIndexes.get(key) ?? [];
453
+ bucket.push(entry);
454
+ providerIndexes.set(key, bucket);
455
+ }
456
+ const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
457
+ const batchResults = await batchProvider.getTickerFinancialsBatch!(uniqueTargets, options).catch(() => []);
458
+ for (const item of batchResults) {
459
+ if (!item.financials) continue;
460
+ const key = this.cachedFinancialsBatchKey(item.target);
461
+ const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
462
+ if (!value) continue;
463
+ const sourceKey = this.providerSourceKey(batchProvider);
464
+ for (const entry of providerIndexes.get(key) ?? []) {
465
+ const entityKey = this.getEntityKey(entry.target.symbol, entry.target.exchange, entry.target.instrument ?? undefined);
466
+ const variantKey = this.getTickerVariantCandidates(entry.target.exchange)[0] ?? "";
467
+ this.cacheResource("financials", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("financials", batchProvider));
468
+ results[entry.index] = { target: entry.target, financials: value };
469
+ }
470
+ }
471
+ }
472
+
473
+ await Promise.all(misses.map(async ({ index, target }) => {
474
+ if (results[index]) return;
475
+ try {
476
+ const financials = await this.getTickerFinancials(target.symbol, target.exchange, {
477
+ ...this.contextFromCachedTarget(target),
478
+ cacheMode: forceRefresh ? "refresh" : "default",
479
+ });
480
+ results[index] = { target, financials };
481
+ } catch (error) {
482
+ results[index] = { target, financials: null, error };
483
+ }
484
+ }));
485
+
486
+ return results.map((result, index) => result ?? { target: targets[index]!, financials: null });
487
+ }
488
+
352
489
  async getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials> {
353
490
  const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false);
354
491
  const forceRefresh = context?.cacheMode === "refresh";
@@ -522,9 +659,9 @@ export class AssetDataRouter implements DataProvider {
522
659
  async getAnalystResearch(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<AnalystResearchData> {
523
660
  const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
524
661
  const variantKeys = this.getTickerVariantCandidates(exchange);
525
- const cached = this.selectCachedResource<AnalystResearchData>("analystResearch", entityKey, variantKeys, this.getProviderSourceKeys(), false);
662
+ const cached = this.selectCachedAnalystResearch(entityKey, variantKeys, this.getProviderSourceKeys(), false);
526
663
  const forceRefresh = context?.cacheMode === "refresh";
527
- if (cached && !forceRefresh) {
664
+ if (cached && !forceRefresh && !isAnalystResearchMissingRatingTargets(cached.value)) {
528
665
  this.scheduleRevalidation(this.makeRevalidationKey("analystResearch", ticker, exchange, context), async () => {
529
666
  await this.revalidateAnalystResearch(ticker, exchange, context);
530
667
  });
@@ -880,7 +1017,17 @@ export class AssetDataRouter implements DataProvider {
880
1017
  sourceKeys: string[],
881
1018
  allowExpired: boolean,
882
1019
  ): CachedResourceRecord<T> | null {
883
- return this.listCachedResources(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
1020
+ return this.listCachedResources<T>(kind, entityKey, variantKeys, sourceKeys, allowExpired)[0] ?? null;
1021
+ }
1022
+
1023
+ private selectCachedAnalystResearch(
1024
+ entityKey: string,
1025
+ variantKeys: string[],
1026
+ sourceKeys: string[],
1027
+ allowExpired: boolean,
1028
+ ): CachedResourceRecord<AnalystResearchData> | null {
1029
+ const records = this.listCachedResources<AnalystResearchData>("analystResearch", entityKey, variantKeys, sourceKeys, allowExpired);
1030
+ return records.find((record) => !isAnalystResearchMissingRatingTargets(record.value)) ?? records[0] ?? null;
884
1031
  }
885
1032
 
886
1033
  private selectCachedArrayResource<T>(
@@ -1009,14 +1156,42 @@ export class AssetDataRouter implements DataProvider {
1009
1156
  );
1010
1157
  }
1011
1158
 
1012
- private getStreamingBrokerCandidate(target: QuoteSubscriptionTarget): BrokerCandidate | null {
1013
- const hasBrokerContext = !!(
1014
- target.context?.brokerId
1015
- || target.context?.brokerInstanceId
1016
- || target.context?.instrument?.brokerId
1017
- || target.context?.instrument?.brokerInstanceId
1159
+ private hasBrokerContext(context?: MarketDataRequestContext): boolean {
1160
+ return !!(
1161
+ context?.brokerId ||
1162
+ context?.brokerInstanceId ||
1163
+ context?.instrument?.brokerId ||
1164
+ context?.instrument?.brokerInstanceId
1165
+ );
1166
+ }
1167
+
1168
+ private hasCachedTargetBrokerContext(target: CachedFinancialsTarget): boolean {
1169
+ return !!(
1170
+ target.brokerId ||
1171
+ target.brokerInstanceId ||
1172
+ target.instrument?.brokerId ||
1173
+ target.instrument?.brokerInstanceId
1018
1174
  );
1019
- if (!hasBrokerContext) return null;
1175
+ }
1176
+
1177
+ private contextFromCachedTarget(target: CachedFinancialsTarget): MarketDataRequestContext {
1178
+ return {
1179
+ brokerId: target.brokerId,
1180
+ brokerInstanceId: target.brokerInstanceId,
1181
+ instrument: target.instrument ?? null,
1182
+ };
1183
+ }
1184
+
1185
+ private quoteBatchKey(target: QuoteSubscriptionTarget): string {
1186
+ return `${target.symbol.trim().toUpperCase()}:${canonicalExchange(target.exchange ?? "")}`;
1187
+ }
1188
+
1189
+ private cachedFinancialsBatchKey(target: CachedFinancialsTarget): string {
1190
+ return `${target.symbol.trim().toUpperCase()}:${canonicalExchange(target.exchange ?? "")}`;
1191
+ }
1192
+
1193
+ private getStreamingBrokerCandidate(target: QuoteSubscriptionTarget): BrokerCandidate | null {
1194
+ if (!this.hasBrokerContext(target.context)) return null;
1020
1195
  return this.getBrokerCandidatesForContext(target.context, false)
1021
1196
  .find((candidate) => typeof candidate.broker.subscribeQuotes === "function") ?? null;
1022
1197
  }
@@ -1583,6 +1758,7 @@ export class AssetDataRouter implements DataProvider {
1583
1758
  const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1584
1759
  const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
1585
1760
  let firstEmptyResult: SourceResult<AnalystResearchData> | null = null;
1761
+ let firstIncompleteResult: SourceResult<AnalystResearchData> | null = null;
1586
1762
  let lastError: unknown = null;
1587
1763
 
1588
1764
  for (const provider of this.providersInPriorityOrder()) {
@@ -1591,7 +1767,13 @@ export class AssetDataRouter implements DataProvider {
1591
1767
  const value = await provider.getAnalystResearch(ticker, exchange, context);
1592
1768
  const sourceKey = this.providerSourceKey(provider);
1593
1769
  this.cacheResource("analystResearch", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("analystResearch", provider));
1594
- if (hasAnalystResearchValue(value)) return { sourceKey, value };
1770
+ if (hasAnalystResearchValue(value)) {
1771
+ if (isAnalystResearchMissingRatingTargets(value)) {
1772
+ firstIncompleteResult ??= { sourceKey, value };
1773
+ continue;
1774
+ }
1775
+ return { sourceKey, value };
1776
+ }
1595
1777
  firstEmptyResult ??= { sourceKey, value };
1596
1778
  } catch (error) {
1597
1779
  lastError = error;
@@ -1601,6 +1783,7 @@ export class AssetDataRouter implements DataProvider {
1601
1783
  }
1602
1784
  }
1603
1785
 
1786
+ if (firstIncompleteResult) return firstIncompleteResult;
1604
1787
  if (firstEmptyResult) return firstEmptyResult;
1605
1788
  if (lastError) throw lastError;
1606
1789
  return null;
@@ -32,4 +32,48 @@ describe("YahooFinanceClient exchange aliases", () => {
32
32
  });
33
33
  expect(history[0]?.close).toBe(200);
34
34
  });
35
+
36
+ test("preserves analyst rating price targets from upgrade history", async () => {
37
+ const provider = new YahooFinanceClient() as any;
38
+ let requestUrl = "";
39
+ provider.getSymbolsToTry = () => ["AMD"];
40
+ provider.fetchJsonWithCrumb = async (_label: string, url: string) => {
41
+ requestUrl = url;
42
+ return {
43
+ quoteSummary: {
44
+ result: [{
45
+ price: { symbol: "AMD", currency: "USD", exchangeName: "NasdaqGS" },
46
+ financialData: {},
47
+ recommendationTrend: { trend: [] },
48
+ earningsTrend: { trend: [] },
49
+ upgradeDowngradeHistory: {
50
+ history: [{
51
+ epochGradeDate: 1778188318,
52
+ firm: "Citigroup",
53
+ toGrade: "Neutral",
54
+ fromGrade: "Neutral",
55
+ action: "main",
56
+ priceTargetAction: "Raises",
57
+ currentPriceTarget: 358,
58
+ priorPriceTarget: 248,
59
+ }],
60
+ },
61
+ }],
62
+ },
63
+ };
64
+ };
65
+
66
+ const research = await provider.getAnalystResearch("AMD", "NASDAQ");
67
+
68
+ expect(requestUrl).toContain("upgradeDowngradeHistory");
69
+ expect(research.ratings[0]).toMatchObject({
70
+ date: "2026-05-07",
71
+ firm: "Citigroup",
72
+ action: "Raises",
73
+ current: "Neutral",
74
+ prior: "Neutral",
75
+ currentPriceTarget: 358,
76
+ priorPriceTarget: 248,
77
+ });
78
+ });
35
79
  });
@@ -179,6 +179,8 @@ type QuoteSummaryResponse = {
179
179
  priceTargetAction?: string;
180
180
  toGrade?: string;
181
181
  fromGrade?: string;
182
+ currentPriceTarget?: { raw?: number } | number | null;
183
+ priorPriceTarget?: { raw?: number } | number | null;
182
184
  }>;
183
185
  };
184
186
  earningsTrend?: {
@@ -412,12 +414,16 @@ function mapYahooAnalystResearchResponse(
412
414
  const action = normalizeYahooRatingAction(rating.action, rating.priceTargetAction);
413
415
  const current = rating.toGrade?.trim();
414
416
  const prior = rating.fromGrade?.trim();
417
+ const currentPriceTarget = financeRawNumber(rating.currentPriceTarget);
418
+ const priorPriceTarget = financeRawNumber(rating.priorPriceTarget);
415
419
  return {
416
420
  date,
417
421
  firm,
418
422
  ...(action ? { action } : {}),
419
423
  ...(current ? { current } : {}),
420
424
  ...(prior ? { prior } : {}),
425
+ ...(currentPriceTarget != null ? { currentPriceTarget } : {}),
426
+ ...(priorPriceTarget != null ? { priorPriceTarget } : {}),
421
427
  };
422
428
  })
423
429
  .filter((rating): rating is AnalystResearchData["ratings"][number] => rating !== null)
@@ -54,6 +54,8 @@ export interface InitializeAppStateArgs {
54
54
  primeCachedFinancials?: (entries: Array<{ ticker: TickerRecord; financials: TickerFinancials }>) => void;
55
55
  refreshTicker: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
56
56
  refreshQuote: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
57
+ refreshTickersBatch?: (entries: Array<{ ticker: TickerRecord; priority: number }>) => void;
58
+ refreshQuotesBatch?: (entries: Array<{ ticker: TickerRecord; priority: number }>) => void;
57
59
  autoImportBrokerPositions: (tickerMap: Map<string, TickerRecord>) => Promise<void>;
58
60
  persistedBrokerAccounts?: Record<string, BrokerAccount[]>;
59
61
  }
@@ -211,11 +213,11 @@ function buildCachedFinancialTarget(ticker: TickerRecord): CachedFinancialsTarge
211
213
  };
212
214
  }
213
215
 
214
- function resolveCachedFinancialPrimeEntries(
216
+ async function resolveCachedFinancialPrimeEntries(
215
217
  refreshPlan: RefreshPlanEntry[],
216
218
  sessionSnapshot: AppSessionSnapshot | null | undefined,
217
219
  dataProvider: DataProvider,
218
- ): Array<{ ticker: TickerRecord; financials: TickerFinancials }> {
220
+ ): Promise<Array<{ ticker: TickerRecord; financials: TickerFinancials }>> {
219
221
  if (!dataProvider.getCachedFinancialsForTargets) return [];
220
222
 
221
223
  const sessionTargetsBySymbol = new Map<string, CachedFinancialsTarget>();
@@ -226,7 +228,7 @@ function resolveCachedFinancialPrimeEntries(
226
228
  const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
227
229
  if (financialEntries.length === 0) return [];
228
230
 
229
- const cachedFinancials = dataProvider.getCachedFinancialsForTargets(
231
+ const cachedFinancials = await dataProvider.getCachedFinancialsForTargets(
230
232
  financialEntries.map(({ ticker }) => (
231
233
  sessionTargetsBySymbol.get(ticker.metadata.ticker.trim().toUpperCase()) ?? buildCachedFinancialTarget(ticker)
232
234
  )),
@@ -252,6 +254,8 @@ export async function initializeAppState({
252
254
  primeCachedFinancials,
253
255
  refreshTicker,
254
256
  refreshQuote,
257
+ refreshTickersBatch,
258
+ refreshQuotesBatch,
255
259
  autoImportBrokerPositions,
256
260
  persistedBrokerAccounts = {},
257
261
  }: InitializeAppStateArgs): Promise<void> {
@@ -341,7 +345,7 @@ export async function initializeAppState({
341
345
  });
342
346
 
343
347
  if (primeCachedFinancials) {
344
- const cachedPrimeEntries = measurePerf(
348
+ const cachedPrimeEntries = await measurePerfAsync(
345
349
  "startup.resolve-cached-financial-prime",
346
350
  () => resolveCachedFinancialPrimeEntries(refreshPlan, sessionSnapshot, dataProvider),
347
351
  { financialRefreshCount: refreshPlan.filter((entry) => entry.mode === "financials").length },
@@ -362,10 +366,19 @@ export async function initializeAppState({
362
366
  });
363
367
 
364
368
  measurePerf("startup.enqueue-refresh-plan", () => {
365
- for (const entry of refreshPlan) {
366
- if (entry.mode === "financials") {
369
+ const financialEntries = refreshPlan.filter((entry) => entry.mode === "financials");
370
+ const quoteEntries = refreshPlan.filter((entry) => entry.mode === "quote");
371
+ if (refreshTickersBatch) {
372
+ refreshTickersBatch(financialEntries.map((entry) => ({ ticker: entry.ticker, priority: entry.priority })));
373
+ } else {
374
+ for (const entry of financialEntries) {
367
375
  refreshTicker(entry.ticker.metadata.ticker, entry.ticker.metadata.exchange, entry.ticker, entry.priority);
368
- } else {
376
+ }
377
+ }
378
+ if (refreshQuotesBatch) {
379
+ refreshQuotesBatch(quoteEntries.map((entry) => ({ ticker: entry.ticker, priority: entry.priority })));
380
+ } else {
381
+ for (const entry of quoteEntries) {
369
382
  refreshQuote(entry.ticker.metadata.ticker, entry.ticker.metadata.exchange, entry.ticker, entry.priority);
370
383
  }
371
384
  }