gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. 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,16 +224,12 @@ 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
 
@@ -209,6 +240,53 @@ function hasCachedSnapshotData(financials: TickerFinancials): boolean {
209
240
  || financials.quarterlyStatements.length > 0;
210
241
  }
211
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
+
212
290
  function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
213
291
  return {
214
292
  ...current,
@@ -231,12 +309,10 @@ export class MarketDataCoordinator {
231
309
  private readonly keyVersions = new Map<string, number>();
232
310
  private readonly inFlight = new Map<string, Promise<unknown>>();
233
311
  private readonly chartRequests = new Map<string, ChartRequest>();
234
- private readonly quoteSubscriptions = new Map<string, {
235
- target: QuoteSubscriptionTarget;
236
- refCount: number;
237
- unsubscribe: (() => void) | null;
238
- removeTimer: ReturnType<typeof setTimeout> | null;
239
- }>();
312
+ private readonly quoteSubscriptions = new Map<string, QuoteSubscriptionEntry>();
313
+ private nextQuoteSubscriptionId = 1;
314
+ private quoteSubscriptionDispose: (() => void) | null = null;
315
+ private quoteSubscriptionSignature = "";
240
316
  private pendingQuoteSubscriptionKeys = new Set<string>();
241
317
 
242
318
  private readonly quoteStore = new QueryStore<Quote>((key) => this.bump(key));
@@ -452,11 +528,15 @@ export class MarketDataCoordinator {
452
528
  };
453
529
  }
454
530
 
455
- private quoteTargetFromInstrument(instrument: InstrumentRef): QuoteSubscriptionTarget {
531
+ private quoteTargetFromInstrument(
532
+ instrument: InstrumentRef,
533
+ priority: QuoteSubscriptionPriority = {},
534
+ ): QuoteSubscriptionTarget {
456
535
  return {
457
536
  symbol: instrument.symbol,
458
537
  exchange: instrument.exchange ?? "",
459
538
  context: toMarketDataContext(instrument),
539
+ ...priority,
460
540
  };
461
541
  }
462
542
 
@@ -523,19 +603,37 @@ export class MarketDataCoordinator {
523
603
  request: ChartRequest,
524
604
  current: QueryEntry<PricePoint[]>,
525
605
  ): QueryEntry<PricePoint[]> {
526
- if (resolveEntryData(current)?.length) {
527
- 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
+ });
528
613
  }
529
614
 
530
615
  const seed = this.findChartSeedEntry(key, request);
531
616
  if (!seed) {
532
- 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
+ });
533
631
  }
534
632
 
535
633
  return loadingEntry({
536
634
  ...current,
537
- data: seed.data,
538
- lastGoodData: seed.entry.lastGoodData?.length ? seed.entry.lastGoodData : seed.data,
635
+ data: seedData,
636
+ lastGoodData: seedData,
539
637
  source: seed.entry.source,
540
638
  fetchedAt: seed.entry.fetchedAt,
541
639
  staleAt: seed.entry.staleAt,
@@ -668,7 +766,7 @@ export class MarketDataCoordinator {
668
766
  for (const instrument of uniqueInstruments) {
669
767
  const key = buildQuoteKey(instrument);
670
768
  const current = this.quoteStore.get(key);
671
- if (!options.forceRefresh && hasFreshEntryData(current, SNAPSHOT_CACHE_TTL_MS)) {
769
+ if (!options.forceRefresh && hasFreshQuoteEntry(current, instrument, SNAPSHOT_CACHE_TTL_MS)) {
672
770
  results.set(key, current);
673
771
  } else {
674
772
  misses.push(instrument);
@@ -706,7 +804,13 @@ export class MarketDataCoordinator {
706
804
  const key = buildChartKey(request);
707
805
  this.chartRequests.set(key, request);
708
806
  const current = this.chartStore.get(key);
709
- 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
+ }
710
814
  return current;
711
815
  }
712
816
  const flightKey = options.forceRefresh ? `${key}|refresh` : key;
@@ -905,16 +1009,22 @@ export class MarketDataCoordinator {
905
1009
  }
906
1010
 
907
1011
  async loadFxRate(currency: string): Promise<QueryEntry<number>> {
908
- const key = buildFxKey(currency);
1012
+ const normalizedCurrency = currency.trim().toUpperCase();
1013
+ const key = buildFxKey(normalizedCurrency);
909
1014
  const current = this.fxStore.get(key);
910
1015
  if (hasFreshEntryData(current, FX_CACHE_TTL_MS)) {
911
1016
  return current;
912
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
+ }
913
1023
  return this.runSingleFlight(key, async () => {
914
1024
  this.fxStore.update(key, loadingEntry);
915
1025
  const startedAt = Date.now();
916
1026
  try {
917
- const rate = await this.dataProvider.getExchangeRate(currency);
1027
+ const rate = await this.dataProvider.getExchangeRate(normalizedCurrency);
918
1028
  const attempts = [createAttempt(this.dataProvider.id, startedAt, "success")];
919
1029
  return this.fxStore.update(key, (current) => readyEntry(current, rate, this.dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
920
1030
  } catch (error) {
@@ -925,18 +1035,22 @@ export class MarketDataCoordinator {
925
1035
  });
926
1036
  }
927
1037
 
928
- subscribeQuotes(targets: Array<{ instrument: InstrumentRef }>): () => void {
1038
+ subscribeQuotes(targets: Array<{ instrument: InstrumentRef; priority?: QuoteSubscriptionPriority }>): () => void {
929
1039
  if (!this.dataProvider.subscribeQuotes || targets.length === 0) {
930
1040
  return () => {};
931
1041
  }
932
1042
 
1043
+ const subscriptionId = this.nextQuoteSubscriptionId++;
933
1044
  const subscribedKeys = new Set<string>();
934
- for (const { instrument } of targets) {
1045
+ for (const { instrument, priority } of targets) {
935
1046
  const key = buildQuoteKey(instrument);
936
1047
  subscribedKeys.add(key);
1048
+ const target = this.quoteTargetFromInstrument(instrument, priority);
937
1049
  const existing = this.quoteSubscriptions.get(key);
938
1050
  if (existing) {
939
- existing.refCount += 1;
1051
+ existing.targets.set(subscriptionId, target);
1052
+ existing.target = mergeQuoteSubscriptionTargets(existing.targets.values()) ?? target;
1053
+ this.pendingQuoteSubscriptionKeys.add(key);
940
1054
  if (existing.removeTimer) {
941
1055
  clearTimeout(existing.removeTimer);
942
1056
  existing.removeTimer = null;
@@ -944,9 +1058,8 @@ export class MarketDataCoordinator {
944
1058
  continue;
945
1059
  }
946
1060
  this.quoteSubscriptions.set(key, {
947
- target: this.quoteTargetFromInstrument(instrument),
948
- refCount: 1,
949
- unsubscribe: null,
1061
+ target,
1062
+ targets: new Map([[subscriptionId, target]]),
950
1063
  removeTimer: null,
951
1064
  });
952
1065
  this.pendingQuoteSubscriptionKeys.add(key);
@@ -957,44 +1070,68 @@ export class MarketDataCoordinator {
957
1070
  for (const key of subscribedKeys) {
958
1071
  const existing = this.quoteSubscriptions.get(key);
959
1072
  if (!existing) continue;
960
- existing.refCount = Math.max(0, existing.refCount - 1);
961
- 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;
962
1082
  existing.removeTimer = setTimeout(() => {
963
1083
  const current = this.quoteSubscriptions.get(key);
964
- if (!current || current.refCount > 0) return;
965
- current.unsubscribe?.();
1084
+ if (!current || current.targets.size > 0) return;
966
1085
  this.quoteSubscriptions.delete(key);
967
1086
  this.pendingQuoteSubscriptionKeys.delete(key);
1087
+ this.flushQuoteSubscriptions();
968
1088
  }, QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS);
969
1089
  }
970
1090
  };
971
1091
  }
972
1092
 
973
1093
  private flushQuoteSubscriptions(): void {
974
- const keys = this.pendingQuoteSubscriptionKeys;
1094
+ if (!this.dataProvider.subscribeQuotes) return;
975
1095
  this.pendingQuoteSubscriptionKeys = new Set();
976
- for (const key of keys) {
977
- const entry = this.quoteSubscriptions.get(key);
978
- if (!entry || entry.refCount === 0 || entry.unsubscribe) continue;
979
- entry.unsubscribe = this.dataProvider.subscribeQuotes?.([entry.target], (target, quote) => {
980
- const instrument: InstrumentRef = {
981
- symbol: target.symbol,
982
- exchange: target.exchange ?? "",
983
- brokerId: target.context?.brokerId,
984
- brokerInstanceId: target.context?.brokerInstanceId,
985
- instrument: target.context?.instrument ?? null,
986
- };
987
- this.applyStreamQuote(instrument, quote);
988
- }) ?? null;
989
- }
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
+ });
990
1126
  }
991
1127
 
992
1128
  private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
993
1129
  const key = buildQuoteKey(instrument);
994
1130
  const current = this.quoteStore.get(key);
995
1131
  if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
996
- const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
997
- 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));
998
1135
  }
999
1136
  }
1000
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]);
@@ -432,6 +432,9 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
432
432
  const quoteTargets = useMemo(() => (
433
433
  sortedTickers
434
434
  .map((ticker) => quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker))
435
+ .map((target) => target
436
+ ? { ...target, surface: "screener" as const, visible: true, weight: 70 }
437
+ : null)
435
438
  .filter((target): target is NonNullable<ReturnType<typeof quoteSubscriptionTargetFromTicker>> => target != null)
436
439
  ), [sortedTickers]);
437
440
 
@@ -752,24 +752,24 @@ export class ChatController {
752
752
  this.notifiedMessageIds.clear();
753
753
  }
754
754
 
755
- send(content: string, replyToId?: string): void {
756
- this.sendToChannel(DEFAULT_CHAT_CHANNEL_ID, content, replyToId);
755
+ send(content: string, replyToId?: string): boolean {
756
+ return this.sendToChannel(DEFAULT_CHAT_CHANNEL_ID, content, replyToId);
757
757
  }
758
758
 
759
- sendToChannel(channelId: string, content: string, replyToId?: string): void {
759
+ sendToChannel(channelId: string, content: string, replyToId?: string): boolean {
760
760
  const normalizedChannelId = normalizeChannelId(channelId);
761
761
  const channel = this.ensureChannelState(normalizedChannelId);
762
762
  const messageContent = content.trim();
763
- if (!messageContent) return;
764
- if (!this.user?.emailVerified || !this.sessionToken) return;
765
- if (this.hasPendingSend(channel, messageContent, replyToId)) return;
763
+ if (!messageContent) return false;
764
+ if (!this.user?.emailVerified || !this.sessionToken) return false;
765
+ if (this.hasPendingSend(channel, messageContent, replyToId)) return true;
766
766
  if (!channel.wsConnection && this.user?.emailVerified && this.sessionToken) {
767
767
  this.ensureConnection(normalizedChannelId);
768
768
  }
769
769
  const connection = channel.wsConnection;
770
770
  if (!connection) {
771
771
  this.notifyFn({ body: "Unable to send message right now.", type: "error" });
772
- return;
772
+ return false;
773
773
  }
774
774
 
775
775
  const clientMessageId = channel.draftClientMessageId ?? createClientMessageId();
@@ -794,6 +794,7 @@ export class ChatController {
794
794
  this.emit(normalizedChannelId);
795
795
  this.notifyFn({ body: errorMessage, type: "error" });
796
796
  });
797
+ return true;
797
798
  }
798
799
 
799
800
  private hasPendingSend(channel: ChannelRuntimeState, content: string, replyToId?: string): boolean {