gloomberb 0.7.0 → 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 (125) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +63 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +6 -0
  118. package/src/utils/api-client.test.ts +32 -0
  119. package/src/utils/api-client.ts +119 -18
  120. package/src/utils/double-escape-close.test.ts +33 -0
  121. package/src/utils/double-escape-close.ts +40 -0
  122. package/src/utils/throttled-fetch.test.ts +22 -0
  123. package/src/utils/throttled-fetch.ts +3 -1
  124. package/src/utils/ticker-search.ts +53 -9
  125. package/src/version.ts +1 -1
@@ -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";
@@ -150,6 +152,16 @@ function hasAnalystResearchValue(data: AnalystResearchData): boolean {
150
152
  || data.revenueEstimates.length > 0;
151
153
  }
152
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
+
153
165
  function hasCorporateActionsValue(data: CorporateActionsData): boolean {
154
166
  return data.dividends.length > 0
155
167
  || data.splits.length > 0
@@ -345,6 +357,135 @@ export class AssetDataRouter implements DataProvider {
345
357
  return results;
346
358
  }
347
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
+
348
489
  async getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials> {
349
490
  const cached = this.readCachedMergedFinancialsSelection(ticker, exchange, context, false);
350
491
  const forceRefresh = context?.cacheMode === "refresh";
@@ -518,9 +659,9 @@ export class AssetDataRouter implements DataProvider {
518
659
  async getAnalystResearch(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<AnalystResearchData> {
519
660
  const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
520
661
  const variantKeys = this.getTickerVariantCandidates(exchange);
521
- const cached = this.selectCachedResource<AnalystResearchData>("analystResearch", entityKey, variantKeys, this.getProviderSourceKeys(), false);
662
+ const cached = this.selectCachedAnalystResearch(entityKey, variantKeys, this.getProviderSourceKeys(), false);
522
663
  const forceRefresh = context?.cacheMode === "refresh";
523
- if (cached && !forceRefresh) {
664
+ if (cached && !forceRefresh && !isAnalystResearchMissingRatingTargets(cached.value)) {
524
665
  this.scheduleRevalidation(this.makeRevalidationKey("analystResearch", ticker, exchange, context), async () => {
525
666
  await this.revalidateAnalystResearch(ticker, exchange, context);
526
667
  });
@@ -876,7 +1017,17 @@ export class AssetDataRouter implements DataProvider {
876
1017
  sourceKeys: string[],
877
1018
  allowExpired: boolean,
878
1019
  ): CachedResourceRecord<T> | null {
879
- 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;
880
1031
  }
881
1032
 
882
1033
  private selectCachedArrayResource<T>(
@@ -1005,14 +1156,42 @@ export class AssetDataRouter implements DataProvider {
1005
1156
  );
1006
1157
  }
1007
1158
 
1008
- private getStreamingBrokerCandidate(target: QuoteSubscriptionTarget): BrokerCandidate | null {
1009
- const hasBrokerContext = !!(
1010
- target.context?.brokerId
1011
- || target.context?.brokerInstanceId
1012
- || target.context?.instrument?.brokerId
1013
- || 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
1014
1165
  );
1015
- if (!hasBrokerContext) return null;
1166
+ }
1167
+
1168
+ private hasCachedTargetBrokerContext(target: CachedFinancialsTarget): boolean {
1169
+ return !!(
1170
+ target.brokerId ||
1171
+ target.brokerInstanceId ||
1172
+ target.instrument?.brokerId ||
1173
+ target.instrument?.brokerInstanceId
1174
+ );
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;
1016
1195
  return this.getBrokerCandidatesForContext(target.context, false)
1017
1196
  .find((candidate) => typeof candidate.broker.subscribeQuotes === "function") ?? null;
1018
1197
  }
@@ -1579,6 +1758,7 @@ export class AssetDataRouter implements DataProvider {
1579
1758
  const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1580
1759
  const variantKey = this.getTickerVariantCandidates(exchange)[0] ?? "";
1581
1760
  let firstEmptyResult: SourceResult<AnalystResearchData> | null = null;
1761
+ let firstIncompleteResult: SourceResult<AnalystResearchData> | null = null;
1582
1762
  let lastError: unknown = null;
1583
1763
 
1584
1764
  for (const provider of this.providersInPriorityOrder()) {
@@ -1587,7 +1767,13 @@ export class AssetDataRouter implements DataProvider {
1587
1767
  const value = await provider.getAnalystResearch(ticker, exchange, context);
1588
1768
  const sourceKey = this.providerSourceKey(provider);
1589
1769
  this.cacheResource("analystResearch", entityKey, variantKey, sourceKey, value, this.resolveProviderPolicy("analystResearch", provider));
1590
- 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
+ }
1591
1777
  firstEmptyResult ??= { sourceKey, value };
1592
1778
  } catch (error) {
1593
1779
  lastError = error;
@@ -1597,6 +1783,7 @@ export class AssetDataRouter implements DataProvider {
1597
1783
  }
1598
1784
  }
1599
1785
 
1786
+ if (firstIncompleteResult) return firstIncompleteResult;
1600
1787
  if (firstEmptyResult) return firstEmptyResult;
1601
1788
  if (lastError) throw lastError;
1602
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
  }
@@ -3,7 +3,9 @@ import { act, useRef, type Dispatch } from "react";
3
3
  import { testRender } from "../renderers/opentui/test-utils";
4
4
  import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
5
5
  import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
6
- import { applyTheme, getCurrentThemeId } from "../theme/colors";
6
+ import { applyTheme } from "../theme/colors";
7
+ import { useThemeId } from "../theme/theme-context";
8
+ import type { DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "../types/desktop-window";
7
9
 
8
10
  const TEST_PANE_ID = "ticker-detail:test";
9
11
 
@@ -20,6 +22,7 @@ function createTickerDetailConfig(symbol: string): AppConfig {
20
22
  binding: { kind: "fixed" as const, symbol },
21
23
  }],
22
24
  floating: [],
25
+ detached: [],
23
26
  };
24
27
 
25
28
  return {
@@ -43,7 +46,46 @@ function PaneTickerHarness() {
43
46
 
44
47
  function ThemeSelectorHarness() {
45
48
  const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
46
- return <text>{`${focusedPaneId ?? "none"}:${getCurrentThemeId()}`}</text>;
49
+ const themeId = useThemeId();
50
+ return <text>{`${focusedPaneId ?? "none"}:${themeId}`}</text>;
51
+ }
52
+
53
+ function FocusSelectorHarness() {
54
+ const renderCountRef = useRef(0);
55
+ renderCountRef.current += 1;
56
+ const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
57
+ return <text>{`${focusedPaneId ?? "none"}:${renderCountRef.current}`}</text>;
58
+ }
59
+
60
+ function createDesktopBridge(
61
+ kind: "main" | "detached",
62
+ calls: {
63
+ mainSnapshots?: DesktopSharedStateSnapshot[];
64
+ themePreviews?: DesktopThemePreviewState[];
65
+ onThemePreviewSubscribe?: (listener: (preview: DesktopThemePreviewState) => void) => void;
66
+ } = {},
67
+ ): DesktopWindowBridge {
68
+ return {
69
+ kind,
70
+ paneId: kind === "detached" ? TEST_PANE_ID : undefined,
71
+ syncMainState: kind === "main"
72
+ ? async (snapshot) => {
73
+ calls.mainSnapshots?.push(snapshot);
74
+ }
75
+ : undefined,
76
+ syncThemePreview: kind === "main"
77
+ ? async (preview) => {
78
+ calls.themePreviews?.push(preview);
79
+ }
80
+ : undefined,
81
+ subscribeState: () => () => {},
82
+ subscribeThemePreview: kind === "detached"
83
+ ? (listener) => {
84
+ calls.onThemePreviewSubscribe?.(listener);
85
+ return () => {};
86
+ }
87
+ : undefined,
88
+ };
47
89
  }
48
90
 
49
91
  describe("pane selectors", () => {
@@ -100,7 +142,28 @@ describe("pane selectors", () => {
100
142
  expect(testSetup.captureCharFrame()).toContain("AAPL:1");
101
143
  });
102
144
 
103
- test("rerenders selector consumers when the theme changes", async () => {
145
+ test("does not rerender non-theme selector consumers when the theme preview changes", async () => {
146
+ testSetup = await testRender(
147
+ <AppProvider config={createTickerDetailConfig("AAPL")}>
148
+ <DispatchCapture />
149
+ <FocusSelectorHarness />
150
+ </AppProvider>,
151
+ { width: 32, height: 4 },
152
+ );
153
+
154
+ await testSetup.renderOnce();
155
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:1`);
156
+
157
+ await act(() => {
158
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
159
+ });
160
+
161
+ await testSetup.renderOnce();
162
+ await testSetup.renderOnce();
163
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:1`);
164
+ });
165
+
166
+ test("rerenders theme hook consumers when the theme changes", async () => {
104
167
  testSetup = await testRender(
105
168
  <AppProvider config={createTickerDetailConfig("AAPL")}>
106
169
  <DispatchCapture />
@@ -121,7 +184,7 @@ describe("pane selectors", () => {
121
184
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
122
185
  });
123
186
 
124
- test("rerenders selector consumers when the theme preview changes", async () => {
187
+ test("rerenders theme hook consumers when the theme preview changes", async () => {
125
188
  testSetup = await testRender(
126
189
  <AppProvider config={createTickerDetailConfig("AAPL")}>
127
190
  <DispatchCapture />
@@ -142,6 +205,33 @@ describe("pane selectors", () => {
142
205
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
143
206
  });
144
207
 
208
+ test("set theme updates the theme hook and clears the active preview", async () => {
209
+ testSetup = await testRender(
210
+ <AppProvider config={createTickerDetailConfig("AAPL")}>
211
+ <DispatchCapture />
212
+ <ThemeSelectorHarness />
213
+ </AppProvider>,
214
+ { width: 32, height: 4 },
215
+ );
216
+
217
+ await testSetup.renderOnce();
218
+ await act(() => {
219
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
220
+ });
221
+ await testSetup.renderOnce();
222
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
223
+
224
+ await act(() => {
225
+ capturedDispatch?.({ type: "SET_THEME", theme: "red" });
226
+ });
227
+
228
+ await testSetup.renderOnce();
229
+ await testSetup.renderOnce();
230
+ const frame = testSetup.captureCharFrame();
231
+ expect(frame).toContain(`${TEST_PANE_ID}:red`);
232
+ expect(frame).not.toContain(`${TEST_PANE_ID}:green`);
233
+ });
234
+
145
235
  test("falls back to the committed theme when theme preview clears", async () => {
146
236
  testSetup = await testRender(
147
237
  <AppProvider config={createTickerDetailConfig("AAPL")}>
@@ -181,4 +271,99 @@ describe("pane selectors", () => {
181
271
  await testSetup.renderOnce();
182
272
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
183
273
  });
274
+
275
+ test("uses the initial desktop theme preview on the first provider render", async () => {
276
+ testSetup = await testRender(
277
+ <AppProvider config={createTickerDetailConfig("AAPL")} initialThemePreview={{ theme: "green" }}>
278
+ <ThemeSelectorHarness />
279
+ </AppProvider>,
280
+ { width: 32, height: 4 },
281
+ );
282
+
283
+ await testSetup.renderOnce();
284
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
285
+ });
286
+
287
+ test("desktop main preview sync does not publish the full state snapshot", async () => {
288
+ const mainSnapshots: DesktopSharedStateSnapshot[] = [];
289
+ const themePreviews: DesktopThemePreviewState[] = [];
290
+ const bridge = createDesktopBridge("main", { mainSnapshots, themePreviews });
291
+
292
+ testSetup = await testRender(
293
+ <AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
294
+ <DispatchCapture />
295
+ <ThemeSelectorHarness />
296
+ </AppProvider>,
297
+ { width: 32, height: 4 },
298
+ );
299
+
300
+ await testSetup.renderOnce();
301
+ mainSnapshots.length = 0;
302
+ themePreviews.length = 0;
303
+
304
+ await act(() => {
305
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
306
+ });
307
+
308
+ await testSetup.renderOnce();
309
+ await testSetup.renderOnce();
310
+ expect(themePreviews).toEqual([{ theme: "green" }]);
311
+ expect(mainSnapshots).toHaveLength(0);
312
+ });
313
+
314
+ test("desktop committed theme changes still sync through the config snapshot", async () => {
315
+ const mainSnapshots: DesktopSharedStateSnapshot[] = [];
316
+ const themePreviews: DesktopThemePreviewState[] = [];
317
+ const bridge = createDesktopBridge("main", { mainSnapshots, themePreviews });
318
+
319
+ testSetup = await testRender(
320
+ <AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
321
+ <DispatchCapture />
322
+ <ThemeSelectorHarness />
323
+ </AppProvider>,
324
+ { width: 32, height: 4 },
325
+ );
326
+
327
+ await testSetup.renderOnce();
328
+ mainSnapshots.length = 0;
329
+ themePreviews.length = 0;
330
+
331
+ await act(() => {
332
+ capturedDispatch?.({ type: "SET_THEME", theme: "green" });
333
+ });
334
+
335
+ await testSetup.renderOnce();
336
+ await testSetup.renderOnce();
337
+ expect(mainSnapshots).toHaveLength(1);
338
+ expect(mainSnapshots[0]?.config.theme).toBe("green");
339
+ expect(themePreviews).toHaveLength(0);
340
+ });
341
+
342
+ test("desktop detached windows apply theme preview messages locally", async () => {
343
+ let previewListener: ((preview: DesktopThemePreviewState) => void) | null = null;
344
+ const bridge = createDesktopBridge("detached", {
345
+ onThemePreviewSubscribe: (listener) => {
346
+ previewListener = listener;
347
+ },
348
+ });
349
+
350
+ testSetup = await testRender(
351
+ <AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
352
+ <ThemeSelectorHarness />
353
+ </AppProvider>,
354
+ { width: 32, height: 4 },
355
+ );
356
+
357
+ await testSetup.renderOnce();
358
+ expect(previewListener).not.toBeNull();
359
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:amber`);
360
+
361
+ await act(() => {
362
+ previewListener?.({ theme: "green" });
363
+ });
364
+
365
+ await testSetup.renderOnce();
366
+ await testSetup.renderOnce();
367
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
368
+ });
184
369
  });