gloomberb 0.7.2 → 0.7.4

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 (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -127,6 +127,23 @@ describe("MarketDataCoordinator", () => {
127
127
  expect(calls).toBe(2);
128
128
  });
129
129
 
130
+ it("serves USD exchange rates without hitting the provider", async () => {
131
+ let calls = 0;
132
+ const provider = createProvider({
133
+ getExchangeRate: async () => {
134
+ calls += 1;
135
+ return 1;
136
+ },
137
+ });
138
+ const coordinator = new MarketDataCoordinator(provider);
139
+
140
+ const entry = await coordinator.loadFxRate("usd");
141
+
142
+ expect(entry.data).toBe(1);
143
+ expect(entry.source).toBe("static");
144
+ expect(calls).toBe(0);
145
+ });
146
+
130
147
  it("reuses fresh empty tab query results instead of refetching on reopen", async () => {
131
148
  let newsCalls = 0;
132
149
  let optionsCalls = 0;
@@ -553,6 +570,50 @@ describe("MarketDataCoordinator", () => {
553
570
  }
554
571
  });
555
572
 
573
+ it("drops a stale stream quote when there is no fresh quote to preserve", () => {
574
+ const fixedNow = Date.parse("2026-05-13T21:00:00Z");
575
+ const realDateNow = Date.now;
576
+ Date.now = () => fixedNow;
577
+ try {
578
+ let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
579
+ const provider = createProvider({
580
+ subscribeQuotes: (_targets, onQuote) => {
581
+ streamed = onQuote as typeof streamed;
582
+ return () => {};
583
+ },
584
+ });
585
+ const coordinator = new MarketDataCoordinator(provider);
586
+ const instrument = { symbol: "2337", exchange: "TWSE" };
587
+
588
+ coordinator.subscribeQuotes([{ instrument }]);
589
+ const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
590
+ if (!onStreamed) throw new Error("expected streaming callback");
591
+ onStreamed(
592
+ { symbol: "2337", exchange: "TWSE" },
593
+ {
594
+ symbol: "2337",
595
+ providerId: "gloomberb-cloud",
596
+ dataSource: "delayed",
597
+ price: 150,
598
+ currency: "TWD",
599
+ change: 0,
600
+ changePercent: 0,
601
+ lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
602
+ listingExchangeName: "TWSE",
603
+ marketState: "CLOSED",
604
+ sessionConfidence: "explicit",
605
+ },
606
+ );
607
+
608
+ const entry = coordinator.getQuoteEntry(instrument);
609
+ expect(entry.data).toBeNull();
610
+ expect(entry.lastGoodData).toBeNull();
611
+ expect(coordinator.getTickerFinancialsSync(instrument)?.quote).toBeUndefined();
612
+ } finally {
613
+ Date.now = realDateNow;
614
+ }
615
+ });
616
+
556
617
  it("hydrates ticker financials synchronously from primed cached data", () => {
557
618
  const coordinator = new MarketDataCoordinator(createProvider());
558
619
  const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
@@ -642,7 +703,7 @@ describe("MarketDataCoordinator", () => {
642
703
  currency: "USD",
643
704
  change: 0,
644
705
  changePercent: 0,
645
- lastUpdated: 1_700_000_000_000,
706
+ lastUpdated: Date.now(),
646
707
  }),
647
708
  getQuotesBatch: async (targets) => {
648
709
  batchTargets.push(targets);
@@ -654,7 +715,7 @@ describe("MarketDataCoordinator", () => {
654
715
  currency: "USD",
655
716
  change: 0,
656
717
  changePercent: 0,
657
- lastUpdated: 1_700_000_000_000,
718
+ lastUpdated: Date.now(),
658
719
  },
659
720
  }));
660
721
  },
@@ -672,6 +733,65 @@ describe("MarketDataCoordinator", () => {
672
733
  expect(coordinator.getQuoteEntry(msft).data?.price).toBe(200);
673
734
  });
674
735
 
736
+ it("refreshes primed quote cache when the quote is stale for the current session", async () => {
737
+ const fixedNow = Date.parse("2026-05-13T21:00:00Z");
738
+ const realDateNow = Date.now;
739
+ Date.now = () => fixedNow;
740
+ try {
741
+ const batchTargets: QuoteSubscriptionTarget[][] = [];
742
+ const provider = createProvider({
743
+ getQuotesBatch: async (targets) => {
744
+ batchTargets.push(targets);
745
+ return targets.map((target) => ({
746
+ target,
747
+ quote: {
748
+ symbol: target.symbol,
749
+ price: 6970,
750
+ currency: "JPY",
751
+ change: 370,
752
+ changePercent: 5.61,
753
+ lastUpdated: Date.parse("2026-05-13T06:24:00Z"),
754
+ marketState: "CLOSED",
755
+ listingExchangeName: "JPX",
756
+ providerId: "yahoo",
757
+ },
758
+ }));
759
+ },
760
+ });
761
+ const coordinator = new MarketDataCoordinator(provider);
762
+ const instrument = { symbol: "6324.T", exchange: "JPX" };
763
+
764
+ coordinator.primeCachedFinancials([{
765
+ instrument,
766
+ financials: {
767
+ quote: {
768
+ symbol: "6324.T",
769
+ price: 6230,
770
+ currency: "JPY",
771
+ change: 520,
772
+ changePercent: 9.11,
773
+ lastUpdated: Date.parse("2026-05-08T06:24:00Z"),
774
+ marketState: "CLOSED",
775
+ listingExchangeName: "JPX",
776
+ providerId: "gloomberb-cloud",
777
+ },
778
+ annualStatements: [],
779
+ quarterlyStatements: [],
780
+ priceHistory: [],
781
+ },
782
+ }]);
783
+
784
+ await coordinator.loadQuotesBatch([instrument]);
785
+
786
+ expect(batchTargets).toHaveLength(1);
787
+ expect(batchTargets[0]?.map((target) => target.symbol)).toEqual(["6324.T"]);
788
+ expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(6970);
789
+ expect(coordinator.getQuoteEntry(instrument).data?.changePercent).toBe(5.61);
790
+ } finally {
791
+ Date.now = realDateNow;
792
+ }
793
+ });
794
+
675
795
  it("batch loads snapshots through provider batch support", async () => {
676
796
  const batchSymbols: string[][] = [];
677
797
  const provider = createProvider({
@@ -23,9 +23,9 @@ import {
23
23
  toMarketDataContext,
24
24
  } from "./selectors";
25
25
  import { traceMarketData } from "./trace";
26
- import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
26
+ import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
27
27
  import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
28
- import { TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
28
+ import { isIntradayResolution, TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
29
29
  import { measurePerf } from "../utils/perf-marks";
30
30
 
31
31
  const EMPTY_MESSAGE = "No data available";
@@ -57,6 +57,29 @@ function getTimeRangeIndex(range: ChartRequest["bufferRange"]): number {
57
57
  return TIME_RANGE_INDEX.get(range) ?? 0;
58
58
  }
59
59
 
60
+ function isCurrentHistoryWindow(endDate?: Date): boolean {
61
+ if (!endDate) return true;
62
+ const endMs = endDate.getTime();
63
+ return Number.isFinite(endMs) && Date.now() - endMs < 60 * 60_000;
64
+ }
65
+
66
+ function isIntradayChartRequest(request: ChartRequest): boolean {
67
+ const granularity = getChartGranularity(request);
68
+ if (granularity === "detail") return isCurrentHistoryWindow(request.endDate);
69
+ if (granularity === "resolution") {
70
+ return request.resolution ? isIntradayResolution(request.resolution) : false;
71
+ }
72
+ return request.bufferRange === "1D" || request.bufferRange === "1W" || request.bufferRange === "1M" || request.bufferRange === "3M";
73
+ }
74
+
75
+ function normalizeFreshChartData(points: PricePoint[] | null | undefined, request: ChartRequest): PricePoint[] {
76
+ const normalized = normalizePriceHistory(points ?? []);
77
+ if (isIntradayChartRequest(request) && isPriceHistoryStaleForCurrentWindow(normalized)) {
78
+ return [];
79
+ }
80
+ return normalized;
81
+ }
82
+
60
83
  function isSeedableChartRequest(
61
84
  target: ChartRequest,
62
85
  candidate: ChartRequest,
@@ -88,6 +111,20 @@ function hasFreshReadyEntry<T>(entry: QueryEntry<T>, ttlMs: number, now = Date.n
88
111
  return entry.phase === "ready" && entry.fetchedAt != null && now - entry.fetchedAt < ttlMs;
89
112
  }
90
113
 
114
+ function hasFreshQuoteEntry(
115
+ entry: QueryEntry<Quote>,
116
+ instrument: InstrumentRef,
117
+ ttlMs: number,
118
+ now = Date.now(),
119
+ ): boolean {
120
+ const quote = resolveEntryData(entry);
121
+ if (!quote || entry.fetchedAt == null || now - entry.fetchedAt >= ttlMs) return false;
122
+ const quoteForFreshness = instrument.exchange && !quote.listingExchangeName && !quote.exchangeName
123
+ ? { ...quote, listingExchangeName: instrument.exchange }
124
+ : quote;
125
+ return !isQuoteStaleForCurrentSession(quoteForFreshness, now);
126
+ }
127
+
91
128
  function createAttempt(
92
129
  providerId: string,
93
130
  startedAt: number,
@@ -142,11 +179,9 @@ function readyQuoteEntry(
142
179
  source: string,
143
180
  attempts: ProviderAttempt[],
144
181
  ): QueryEntry<Quote> {
145
- if (
146
- isQuoteStaleForCurrentSession(quote)
147
- && hasFreshQuoteForCurrentSession([current.data, current.lastGoodData])
148
- ) {
149
- return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: true });
182
+ if (isQuoteStaleForCurrentSession(quote)) {
183
+ const keepFreshQuote = hasFreshQuoteForCurrentSession([current.data, current.lastGoodData]);
184
+ return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: keepFreshQuote });
150
185
  }
151
186
  return readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true });
152
187
  }
@@ -189,19 +224,69 @@ const STREAM_QUOTE_FIELDS: Array<keyof Quote> = [
189
224
  "dataSource",
190
225
  ];
191
226
 
192
- function quoteTimestampMinute(quote: Quote): number | null {
193
- return Number.isFinite(quote.lastUpdated) ? Math.floor(quote.lastUpdated / 60_000) : null;
194
- }
195
-
196
227
  function areStreamQuotesEquivalent(current: Quote | null | undefined, next: Quote): boolean {
197
228
  if (!current) return false;
198
229
  for (const field of STREAM_QUOTE_FIELDS) {
199
230
  if (current[field] !== next[field]) return false;
200
231
  }
201
- return quoteTimestampMinute(current) === quoteTimestampMinute(next)
232
+ return current.lastUpdated === next.lastUpdated
202
233
  && JSON.stringify(current.provenance ?? null) === JSON.stringify(next.provenance ?? null);
203
234
  }
204
235
 
236
+ function hasCachedSnapshotData(financials: TickerFinancials): boolean {
237
+ return !!financials.profile
238
+ || Object.keys(financials.fundamentals ?? {}).length > 0
239
+ || financials.annualStatements.length > 0
240
+ || financials.quarterlyStatements.length > 0;
241
+ }
242
+
243
+ type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "surface" | "visible" | "selected" | "weight">;
244
+
245
+ interface QuoteSubscriptionEntry {
246
+ target: QuoteSubscriptionTarget;
247
+ targets: Map<number, QuoteSubscriptionTarget>;
248
+ removeTimer: ReturnType<typeof setTimeout> | null;
249
+ }
250
+
251
+ function quoteSubscriptionPriorityScore(target: QuoteSubscriptionTarget): number {
252
+ let score = Number.isFinite(target.weight) ? Math.max(0, target.weight ?? 0) : 0;
253
+ if (target.selected) score += 10_000;
254
+ if (target.visible) score += 5_000;
255
+ if (target.surface === "detail" || target.surface === "monitor") score += 4_000;
256
+ if (target.surface === "portfolio" || target.surface === "watchlist") score += 1_000;
257
+ if (target.surface === "screener") score += 700;
258
+ if (target.surface === "inline") score += 200;
259
+ return score;
260
+ }
261
+
262
+ function mergeQuoteSubscriptionTargets(targets: Iterable<QuoteSubscriptionTarget>): QuoteSubscriptionTarget | null {
263
+ let selectedTarget: QuoteSubscriptionTarget | null = null;
264
+ let selectedScore = -1;
265
+ let visible = false;
266
+ let selected = false;
267
+ let weight = 0;
268
+
269
+ for (const target of targets) {
270
+ const score = quoteSubscriptionPriorityScore(target);
271
+ if (!selectedTarget || score > selectedScore) {
272
+ selectedTarget = target;
273
+ selectedScore = score;
274
+ }
275
+ visible ||= target.visible === true;
276
+ selected ||= target.selected === true;
277
+ weight = Math.max(weight, Number.isFinite(target.weight) ? Math.max(0, target.weight ?? 0) : 0);
278
+ }
279
+
280
+ return selectedTarget
281
+ ? {
282
+ ...selectedTarget,
283
+ visible,
284
+ selected,
285
+ weight,
286
+ }
287
+ : null;
288
+ }
289
+
205
290
  function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
206
291
  return {
207
292
  ...current,
@@ -224,12 +309,10 @@ export class MarketDataCoordinator {
224
309
  private readonly keyVersions = new Map<string, number>();
225
310
  private readonly inFlight = new Map<string, Promise<unknown>>();
226
311
  private readonly chartRequests = new Map<string, ChartRequest>();
227
- private readonly quoteSubscriptions = new Map<string, {
228
- target: QuoteSubscriptionTarget;
229
- refCount: number;
230
- unsubscribe: (() => void) | null;
231
- removeTimer: ReturnType<typeof setTimeout> | null;
232
- }>();
312
+ private readonly quoteSubscriptions = new Map<string, QuoteSubscriptionEntry>();
313
+ private nextQuoteSubscriptionId = 1;
314
+ private quoteSubscriptionDispose: (() => void) | null = null;
315
+ private quoteSubscriptionSignature = "";
233
316
  private pendingQuoteSubscriptionKeys = new Set<string>();
234
317
 
235
318
  private readonly quoteStore = new QueryStore<Quote>((key) => this.bump(key));
@@ -365,7 +448,7 @@ export class MarketDataCoordinator {
365
448
  const fundamentalsKey = buildFundamentalsKey(instrument);
366
449
  const statementsKey = buildStatementsKey(instrument);
367
450
 
368
- if (this.snapshotStore.get(snapshotKey).phase === "idle") {
451
+ if (hasCachedSnapshotData(normalized) && this.snapshotStore.get(snapshotKey).phase === "idle") {
369
452
  this.snapshotStore.set(
370
453
  snapshotKey,
371
454
  readyEntry(this.snapshotStore.get(snapshotKey), normalized, source, [], { keepLastGoodOnEmpty: true }),
@@ -445,11 +528,15 @@ export class MarketDataCoordinator {
445
528
  };
446
529
  }
447
530
 
448
- private quoteTargetFromInstrument(instrument: InstrumentRef): QuoteSubscriptionTarget {
531
+ private quoteTargetFromInstrument(
532
+ instrument: InstrumentRef,
533
+ priority: QuoteSubscriptionPriority = {},
534
+ ): QuoteSubscriptionTarget {
449
535
  return {
450
536
  symbol: instrument.symbol,
451
537
  exchange: instrument.exchange ?? "",
452
538
  context: toMarketDataContext(instrument),
539
+ ...priority,
453
540
  };
454
541
  }
455
542
 
@@ -516,19 +603,37 @@ export class MarketDataCoordinator {
516
603
  request: ChartRequest,
517
604
  current: QueryEntry<PricePoint[]>,
518
605
  ): QueryEntry<PricePoint[]> {
519
- if (resolveEntryData(current)?.length) {
520
- return loadingEntry(current);
606
+ const currentData = normalizeFreshChartData(resolveEntryData(current), request);
607
+ if (currentData.length) {
608
+ return loadingEntry({
609
+ ...current,
610
+ data: currentData,
611
+ lastGoodData: currentData,
612
+ });
521
613
  }
522
614
 
523
615
  const seed = this.findChartSeedEntry(key, request);
524
616
  if (!seed) {
525
- return loadingEntry(current);
617
+ return loadingEntry({
618
+ ...current,
619
+ data: null,
620
+ lastGoodData: null,
621
+ });
622
+ }
623
+
624
+ const seedData = normalizeFreshChartData(seed.data, request);
625
+ if (!seedData.length) {
626
+ return loadingEntry({
627
+ ...current,
628
+ data: null,
629
+ lastGoodData: null,
630
+ });
526
631
  }
527
632
 
528
633
  return loadingEntry({
529
634
  ...current,
530
- data: seed.data,
531
- lastGoodData: seed.entry.lastGoodData?.length ? seed.entry.lastGoodData : seed.data,
635
+ data: seedData,
636
+ lastGoodData: seedData,
532
637
  source: seed.entry.source,
533
638
  fetchedAt: seed.entry.fetchedAt,
534
639
  staleAt: seed.entry.staleAt,
@@ -661,7 +766,7 @@ export class MarketDataCoordinator {
661
766
  for (const instrument of uniqueInstruments) {
662
767
  const key = buildQuoteKey(instrument);
663
768
  const current = this.quoteStore.get(key);
664
- if (!options.forceRefresh && hasFreshEntryData(current, SNAPSHOT_CACHE_TTL_MS)) {
769
+ if (!options.forceRefresh && hasFreshQuoteEntry(current, instrument, SNAPSHOT_CACHE_TTL_MS)) {
665
770
  results.set(key, current);
666
771
  } else {
667
772
  misses.push(instrument);
@@ -699,7 +804,13 @@ export class MarketDataCoordinator {
699
804
  const key = buildChartKey(request);
700
805
  this.chartRequests.set(key, request);
701
806
  const current = this.chartStore.get(key);
702
- if (!options.forceRefresh && hasFreshEntryData(current, CHART_CACHE_TTL_MS)) {
807
+ const currentData = normalizeFreshChartData(resolveEntryData(current), request);
808
+ if (!options.forceRefresh && currentData.length > 0 && hasFreshEntryData(current, CHART_CACHE_TTL_MS)) {
809
+ if (currentData !== resolveEntryData(current)) {
810
+ return this.chartStore.update(key, (entry) =>
811
+ readyEntry(entry, currentData, entry.source ?? this.dataProvider.id, entry.attempts, { keepLastGoodOnEmpty: true })
812
+ );
813
+ }
703
814
  return current;
704
815
  }
705
816
  const flightKey = options.forceRefresh ? `${key}|refresh` : key;
@@ -898,16 +1009,22 @@ export class MarketDataCoordinator {
898
1009
  }
899
1010
 
900
1011
  async loadFxRate(currency: string): Promise<QueryEntry<number>> {
901
- const key = buildFxKey(currency);
1012
+ const normalizedCurrency = currency.trim().toUpperCase();
1013
+ const key = buildFxKey(normalizedCurrency);
902
1014
  const current = this.fxStore.get(key);
903
1015
  if (hasFreshEntryData(current, FX_CACHE_TTL_MS)) {
904
1016
  return current;
905
1017
  }
1018
+ if (normalizedCurrency === "USD") {
1019
+ const startedAt = Date.now();
1020
+ const attempts = [createAttempt("static", startedAt, "success")];
1021
+ return this.fxStore.update(key, (current) => readyEntry(current, 1, "static", attempts, { keepLastGoodOnEmpty: true }));
1022
+ }
906
1023
  return this.runSingleFlight(key, async () => {
907
1024
  this.fxStore.update(key, loadingEntry);
908
1025
  const startedAt = Date.now();
909
1026
  try {
910
- const rate = await this.dataProvider.getExchangeRate(currency);
1027
+ const rate = await this.dataProvider.getExchangeRate(normalizedCurrency);
911
1028
  const attempts = [createAttempt(this.dataProvider.id, startedAt, "success")];
912
1029
  return this.fxStore.update(key, (current) => readyEntry(current, rate, this.dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
913
1030
  } catch (error) {
@@ -918,18 +1035,22 @@ export class MarketDataCoordinator {
918
1035
  });
919
1036
  }
920
1037
 
921
- subscribeQuotes(targets: Array<{ instrument: InstrumentRef }>): () => void {
1038
+ subscribeQuotes(targets: Array<{ instrument: InstrumentRef; priority?: QuoteSubscriptionPriority }>): () => void {
922
1039
  if (!this.dataProvider.subscribeQuotes || targets.length === 0) {
923
1040
  return () => {};
924
1041
  }
925
1042
 
1043
+ const subscriptionId = this.nextQuoteSubscriptionId++;
926
1044
  const subscribedKeys = new Set<string>();
927
- for (const { instrument } of targets) {
1045
+ for (const { instrument, priority } of targets) {
928
1046
  const key = buildQuoteKey(instrument);
929
1047
  subscribedKeys.add(key);
1048
+ const target = this.quoteTargetFromInstrument(instrument, priority);
930
1049
  const existing = this.quoteSubscriptions.get(key);
931
1050
  if (existing) {
932
- existing.refCount += 1;
1051
+ existing.targets.set(subscriptionId, target);
1052
+ existing.target = mergeQuoteSubscriptionTargets(existing.targets.values()) ?? target;
1053
+ this.pendingQuoteSubscriptionKeys.add(key);
933
1054
  if (existing.removeTimer) {
934
1055
  clearTimeout(existing.removeTimer);
935
1056
  existing.removeTimer = null;
@@ -937,9 +1058,8 @@ export class MarketDataCoordinator {
937
1058
  continue;
938
1059
  }
939
1060
  this.quoteSubscriptions.set(key, {
940
- target: this.quoteTargetFromInstrument(instrument),
941
- refCount: 1,
942
- unsubscribe: null,
1061
+ target,
1062
+ targets: new Map([[subscriptionId, target]]),
943
1063
  removeTimer: null,
944
1064
  });
945
1065
  this.pendingQuoteSubscriptionKeys.add(key);
@@ -950,44 +1070,68 @@ export class MarketDataCoordinator {
950
1070
  for (const key of subscribedKeys) {
951
1071
  const existing = this.quoteSubscriptions.get(key);
952
1072
  if (!existing) continue;
953
- existing.refCount = Math.max(0, existing.refCount - 1);
954
- if (existing.refCount > 0 || existing.removeTimer) continue;
1073
+ existing.targets.delete(subscriptionId);
1074
+ const mergedTarget = mergeQuoteSubscriptionTargets(existing.targets.values());
1075
+ if (mergedTarget) {
1076
+ existing.target = mergedTarget;
1077
+ this.pendingQuoteSubscriptionKeys.add(key);
1078
+ this.flushQuoteSubscriptions();
1079
+ continue;
1080
+ }
1081
+ if (existing.removeTimer) continue;
955
1082
  existing.removeTimer = setTimeout(() => {
956
1083
  const current = this.quoteSubscriptions.get(key);
957
- if (!current || current.refCount > 0) return;
958
- current.unsubscribe?.();
1084
+ if (!current || current.targets.size > 0) return;
959
1085
  this.quoteSubscriptions.delete(key);
960
1086
  this.pendingQuoteSubscriptionKeys.delete(key);
1087
+ this.flushQuoteSubscriptions();
961
1088
  }, QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS);
962
1089
  }
963
1090
  };
964
1091
  }
965
1092
 
966
1093
  private flushQuoteSubscriptions(): void {
967
- const keys = this.pendingQuoteSubscriptionKeys;
1094
+ if (!this.dataProvider.subscribeQuotes) return;
968
1095
  this.pendingQuoteSubscriptionKeys = new Set();
969
- for (const key of keys) {
970
- const entry = this.quoteSubscriptions.get(key);
971
- if (!entry || entry.refCount === 0 || entry.unsubscribe) continue;
972
- entry.unsubscribe = this.dataProvider.subscribeQuotes?.([entry.target], (target, quote) => {
973
- const instrument: InstrumentRef = {
974
- symbol: target.symbol,
975
- exchange: target.exchange ?? "",
976
- brokerId: target.context?.brokerId,
977
- brokerInstanceId: target.context?.brokerInstanceId,
978
- instrument: target.context?.instrument ?? null,
979
- };
980
- this.applyStreamQuote(instrument, quote);
981
- }) ?? null;
982
- }
1096
+
1097
+ const activeEntries = [...this.quoteSubscriptions.entries()]
1098
+ .filter(([, entry]) => entry.targets.size > 0 || entry.removeTimer)
1099
+ .sort(([left], [right]) => left.localeCompare(right));
1100
+ const nextSignature = activeEntries.map(([key, entry]) => [
1101
+ key,
1102
+ entry.target.surface ?? "",
1103
+ entry.target.visible ? "visible" : "",
1104
+ entry.target.selected ? "selected" : "",
1105
+ Number.isFinite(entry.target.weight) ? entry.target.weight : "",
1106
+ ].join(":")).join("|");
1107
+ if (nextSignature === this.quoteSubscriptionSignature) return;
1108
+
1109
+ this.quoteSubscriptionDispose?.();
1110
+ this.quoteSubscriptionDispose = null;
1111
+ this.quoteSubscriptionSignature = nextSignature;
1112
+
1113
+ const targets = activeEntries.map(([, entry]) => entry.target);
1114
+ if (targets.length === 0) return;
1115
+
1116
+ this.quoteSubscriptionDispose = this.dataProvider.subscribeQuotes(targets, (target, quote) => {
1117
+ const instrument: InstrumentRef = {
1118
+ symbol: target.symbol,
1119
+ exchange: target.exchange ?? "",
1120
+ brokerId: target.context?.brokerId,
1121
+ brokerInstanceId: target.context?.brokerInstanceId,
1122
+ instrument: target.context?.instrument ?? null,
1123
+ };
1124
+ this.applyStreamQuote(instrument, quote);
1125
+ });
983
1126
  }
984
1127
 
985
1128
  private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
986
1129
  const key = buildQuoteKey(instrument);
987
1130
  const current = this.quoteStore.get(key);
988
1131
  if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
989
- const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
990
- this.quoteStore.set(key, readyQuoteEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts));
1132
+ const receivedAt = Date.now();
1133
+ const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, receivedAt, "success")];
1134
+ this.quoteStore.set(key, readyQuoteEntry(current, { ...quote, receivedAt }, quote.providerId ?? this.dataProvider.id, attempts));
991
1135
  }
992
1136
  }
993
1137
 
@@ -73,6 +73,12 @@ function HooksHarness() {
73
73
  return <text>{String(tick)}</text>;
74
74
  }
75
75
 
76
+ function FxRatesHarness() {
77
+ latestFxRates = useFxRatesMap(["usd", "EUR", null]);
78
+
79
+ return <text>fx</text>;
80
+ }
81
+
76
82
  function ChartQueriesHarness({
77
83
  initialRequests,
78
84
  debounceMs,
@@ -147,6 +153,31 @@ describe("market-data hooks", () => {
147
153
  expect(latestFinancialsMap).toBe(initialFinancialsMap);
148
154
  });
149
155
 
156
+ test("does not load USD exchange rates from providers", async () => {
157
+ const loadedFxCurrencies: string[] = [];
158
+ const coordinator = {
159
+ subscribe: () => () => {},
160
+ getVersion: () => 1,
161
+ getFxEntry: (currency: string) => (currency === "EUR" ? readyEurEntry : readyUsdEntry),
162
+ loadFxRate: async (currency: string) => {
163
+ loadedFxCurrencies.push(currency);
164
+ },
165
+ };
166
+ setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
167
+
168
+ testSetup = await testRender(<FxRatesHarness />, {
169
+ width: 20,
170
+ height: 1,
171
+ });
172
+
173
+ await act(async () => {
174
+ await testSetup!.renderOnce();
175
+ });
176
+
177
+ expect(latestFxRates?.get("USD")).toBe(1);
178
+ expect(loadedFxCurrencies).toEqual(["EUR"]);
179
+ });
180
+
150
181
  test("debounces chart query batches and cancels superseded schedules", async () => {
151
182
  const loadedRanges: string[] = [];
152
183
  const idleChartEntry = createIdleEntry<PricePoint[]>();
@@ -283,6 +283,7 @@ export function useFxRatesMap(currencies: Array<string | null | undefined>): Map
283
283
  const coordinator = getSharedMarketDataCoordinator();
284
284
  if (!coordinator) return;
285
285
  for (const currency of normalizedCurrencies) {
286
+ if (currency === "USD") continue;
286
287
  void coordinator.loadFxRate(currency);
287
288
  }
288
289
  }, [normalizedCurrencyKey]);
@@ -312,4 +312,45 @@ describe("NewsService", () => {
312
312
  expect(detail?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
313
313
  expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
314
314
  });
315
+
316
+ it("keeps story detail identity when a duplicate feed item wins ranking", async () => {
317
+ const url = "https://detail.example.com/duplicate-story";
318
+ const cloudArticle = makeItem({
319
+ id: "story-1",
320
+ url,
321
+ importance: 60,
322
+ publishedAt: new Date("2026-04-01T10:00:00.000Z"),
323
+ items: [],
324
+ });
325
+ const feedDuplicate = makeItem({
326
+ id: "rss-1",
327
+ url,
328
+ importance: 95,
329
+ publishedAt: new Date("2026-04-01T10:05:00.000Z"),
330
+ });
331
+ const detailArticle = makeItem({
332
+ ...cloudArticle,
333
+ items: [{
334
+ id: "item-1",
335
+ sourceKey: "wire-a",
336
+ sourceName: "Wire A",
337
+ title: "Original",
338
+ url: "https://detail.example.com/original",
339
+ publishedAt: new Date("2026-04-01T10:00:00.000Z"),
340
+ }],
341
+ });
342
+
343
+ agg.register({ ...makeStorySource("cloud", [cloudArticle], detailArticle), priority: 10 });
344
+ agg.register({ ...makeSource("rss", [feedDuplicate]), priority: 2000 });
345
+
346
+ const initial = await agg.load({ feed: "top", limit: 10 });
347
+
348
+ expect(initial.articles[0]?.id).toBe("story-1");
349
+ expect(initial.articles[0]?.importance).toBe(95);
350
+
351
+ await agg.loadStory(initial.articles[0]!.id);
352
+ const state = agg.getQueryState({ feed: "top", limit: 10 });
353
+
354
+ expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-1"]);
355
+ });
315
356
  });