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
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { createGloomberbCloudCapabilities, GloomberbCloudProvider } from "./gloomberb-cloud";
3
3
  import type { NewsCapability } from "../capabilities";
4
- import { apiClient, type AuthUser } from "../utils/api-client";
4
+ import { apiClient, type AuthUser, type CloudNewsPayload } from "../utils/api-client";
5
5
 
6
6
  const verifiedUser: AuthUser = {
7
7
  id: "user-1",
@@ -21,8 +21,56 @@ const originalGetCloudHolders = apiClient.getCloudHolders.bind(apiClient);
21
21
  const originalGetCloudAnalystResearch = apiClient.getCloudAnalystResearch.bind(apiClient);
22
22
  const originalGetCloudCorporateActions = apiClient.getCloudCorporateActions.bind(apiClient);
23
23
  const originalGetCloudNews = apiClient.getCloudNews.bind(apiClient);
24
+ const originalGetCloudNewsStory = apiClient.getCloudNewsStory.bind(apiClient);
24
25
  const originalSubscribeQuotes = apiClient.subscribeQuotes.bind(apiClient);
25
26
 
27
+ function makeCloudNewsPayload(overrides: Partial<CloudNewsPayload> = {}): CloudNewsPayload {
28
+ return {
29
+ id: "story-1",
30
+ headline: "Apple raises guidance",
31
+ summary: "Apple lifted its outlook after stronger iPhone demand.",
32
+ topic: "guidance",
33
+ topics: ["guidance"],
34
+ category: "guidance",
35
+ sentiment: "positive",
36
+ sectors: ["information_technology"],
37
+ firstPublishedAt: "2026-04-01T10:00:00.000Z",
38
+ lastPublishedAt: "2026-04-01T10:05:00.000Z",
39
+ firstSeenAt: "2026-04-01T10:00:10.000Z",
40
+ lastSeenAt: "2026-04-01T10:05:10.000Z",
41
+ primaryUrl: "https://example.com/aapl-guidance",
42
+ primarySource: "example-wire",
43
+ scores: {
44
+ importance: 91,
45
+ urgency: 74,
46
+ marketImpact: 88,
47
+ novelty: 86,
48
+ confidence: 95,
49
+ },
50
+ flags: {
51
+ breaking: true,
52
+ developing: false,
53
+ stale: false,
54
+ },
55
+ variantCount: 1,
56
+ sourceCount: 1,
57
+ sources: ["example-wire"],
58
+ entities: [],
59
+ tickerLinks: [{
60
+ symbol: "AAPL",
61
+ exchange: "XNAS",
62
+ canonicalTicker: "AAPL:XNAS",
63
+ relationType: "direct",
64
+ displayTier: "primary",
65
+ confidence: 0.98,
66
+ relevanceScore: 95,
67
+ impactScore: 93,
68
+ sentiment: "positive",
69
+ }],
70
+ ...overrides,
71
+ };
72
+ }
73
+
26
74
  afterEach(() => {
27
75
  apiClient.ensureVerifiedSession = originalEnsureVerifiedSession;
28
76
  apiClient.getCloudHistory = originalGetCloudHistory;
@@ -31,6 +79,7 @@ afterEach(() => {
31
79
  apiClient.getCloudAnalystResearch = originalGetCloudAnalystResearch;
32
80
  apiClient.getCloudCorporateActions = originalGetCloudCorporateActions;
33
81
  apiClient.getCloudNews = originalGetCloudNews;
82
+ apiClient.getCloudNewsStory = originalGetCloudNewsStory;
34
83
  apiClient.subscribeQuotes = originalSubscribeQuotes;
35
84
  });
36
85
 
@@ -496,4 +545,40 @@ describe("GloomberbCloudProvider", () => {
496
545
  confidence: 93,
497
546
  });
498
547
  });
548
+
549
+ test("fetches story detail with ordered source items", async () => {
550
+ let requestedStoryId = "";
551
+ apiClient.getCloudNewsStory = async (storyId) => {
552
+ requestedStoryId = storyId;
553
+ return makeCloudNewsPayload({
554
+ id: storyId,
555
+ items: [{
556
+ id: "item-2",
557
+ sourceKey: "wire-b",
558
+ sourceName: "Wire B",
559
+ title: "Follow-up",
560
+ summary: "More details emerged.",
561
+ url: "https://example.com/follow-up",
562
+ publishedAt: "2026-04-01T10:05:00.000Z",
563
+ hasArticleText: true,
564
+ }, {
565
+ id: "item-1",
566
+ sourceKey: "wire-a",
567
+ sourceName: "Wire A",
568
+ title: "Original",
569
+ summary: "The first report.",
570
+ url: "https://example.com/original",
571
+ publishedAt: "2026-04-01T10:00:00.000Z",
572
+ hasArticleText: false,
573
+ }],
574
+ });
575
+ };
576
+
577
+ const source = createGloomberbCloudCapabilities().find((capability) => capability.kind === "news") as NewsCapability;
578
+ const story = await source.provider.fetchNewsStory?.("story-1");
579
+
580
+ expect(requestedStoryId).toBe("story-1");
581
+ expect(story?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
582
+ expect(story?.items?.[0]?.publishedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
583
+ });
499
584
  });
@@ -5,15 +5,24 @@ import {
5
5
  type ManualChartResolution,
6
6
  } from "../components/chart/chart-resolution";
7
7
  import { assetDataProvider, newsProvider, type PluginCapability } from "../capabilities";
8
- import type { AssetDataProvider, MarketDataRequestContext, NewsItem, QuoteSubscriptionTarget, SearchRequestContext, SecFilingItem } from "../types/data-provider";
8
+ import type {
9
+ AssetDataProvider,
10
+ CachedFinancialsTarget,
11
+ MarketDataRequestContext,
12
+ NewsItem,
13
+ QuoteBatchResult,
14
+ QuoteSubscriptionTarget,
15
+ SearchRequestContext,
16
+ SecFilingItem,
17
+ TickerFinancialsBatchResult,
18
+ } from "../types/data-provider";
9
19
  import type { AnalystResearchData, CorporateActionsData, HolderData, OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
10
20
  import type { InstrumentSearchResult } from "../types/instrument";
11
21
  import {
12
22
  apiClient,
13
23
  type CloudAnalystResearchPayload,
14
- type CloudCompanyProfile,
15
24
  type CloudCorporateActionsPayload,
16
- type CloudFundamentals,
25
+ type CloudMarketBatchItem,
17
26
  type CloudHoldersPayload,
18
27
  type CloudMarketResponse,
19
28
  type CloudNewsPayload,
@@ -103,6 +112,34 @@ function mapPricePoint(point: CloudPricePointPayload, divisor = 1): PricePoint {
103
112
  };
104
113
  }
105
114
 
115
+ function mapCloudFinancials(financials: TickerFinancials): TickerFinancials {
116
+ const quote = financials.quote ? mapQuote(financials.quote as CloudQuotePayload) : undefined;
117
+ const divisor = quote ? resolveCurrencyUnit(quote.currency).divisor : 1;
118
+ return {
119
+ quote,
120
+ quoteContributions: financials.quoteContributions,
121
+ profile: financials.profile,
122
+ fundamentals: financials.fundamentals,
123
+ annualStatements: financials.annualStatements ?? [],
124
+ quarterlyStatements: financials.quarterlyStatements ?? [],
125
+ priceHistory: (financials.priceHistory ?? []).map((point) =>
126
+ point.date instanceof Date
127
+ ? point
128
+ : mapPricePoint(point as unknown as CloudPricePointPayload, divisor)
129
+ ),
130
+ };
131
+ }
132
+
133
+ function mapBatchError<T>(
134
+ item: CloudMarketBatchItem<T>,
135
+ fallbackMessage: string,
136
+ ): Error {
137
+ if (isEmptyCloudStatus(item.status)) {
138
+ return createProviderMiss(item.reasonCode ?? fallbackMessage);
139
+ }
140
+ return new Error(item.reasonCode ?? fallbackMessage);
141
+ }
142
+
106
143
  function normalizeStringArray(value: unknown): string[] {
107
144
  return Array.isArray(value)
108
145
  ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
@@ -358,27 +395,45 @@ export class GloomberbCloudProvider implements AssetDataProvider {
358
395
  async getTickerFinancials(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<TickerFinancials> {
359
396
  await requireVerifiedSession();
360
397
  return withCloudFallback(async () => {
361
- const [quoteResponse, profileResponse, fundamentalsResponse, statementsResponse] = await Promise.all([
362
- apiClient.getCloudQuote(ticker, exchange),
363
- apiClient.getCloudProfile(ticker, exchange),
364
- apiClient.getCloudFundamentals(ticker, exchange),
365
- apiClient.getCloudStatements(ticker, exchange, "both"),
366
- ]);
367
- const quote = mapQuote(unwrapRequiredCloudResponse(quoteResponse, `Cloud quote is unavailable for ${ticker}`));
368
- const profile = unwrapOptionalCloudResponse(profileResponse) as CloudCompanyProfile | null;
369
- const fundamentals = unwrapOptionalCloudResponse(fundamentalsResponse) as CloudFundamentals | null;
370
- const statements = unwrapOptionalCloudResponse(statementsResponse);
371
- return {
372
- quote,
373
- profile: profile ?? undefined,
374
- fundamentals: fundamentals ?? undefined,
375
- annualStatements: statements?.annualStatements ?? [],
376
- quarterlyStatements: statements?.quarterlyStatements ?? [],
377
- priceHistory: [],
378
- };
398
+ const response = await apiClient.getCloudFinancials(ticker, exchange);
399
+ return mapCloudFinancials(unwrapRequiredCloudResponse(response, `Cloud financials are unavailable for ${ticker}`));
379
400
  }, `Cloud financials are unavailable for ${ticker}`);
380
401
  }
381
402
 
403
+ async getTickerFinancialsBatch(
404
+ targets: CachedFinancialsTarget[],
405
+ options: { forceRefresh?: boolean } = {},
406
+ ): Promise<TickerFinancialsBatchResult[]> {
407
+ await requireVerifiedSession();
408
+ return withCloudFallback(async () => {
409
+ const response = await apiClient.getCloudFinancialsBatch(
410
+ targets.map((target) => ({
411
+ symbol: target.symbol,
412
+ exchange: target.exchange,
413
+ })),
414
+ options.forceRefresh ? "refresh" : "cache-first",
415
+ );
416
+ const payload = unwrapRequiredCloudResponse(response, "Cloud financials are unavailable");
417
+ return payload.items.map((item, index) => {
418
+ const target = targets[index] ?? {
419
+ symbol: item.symbol,
420
+ exchange: item.exchange,
421
+ };
422
+ if ((item.status === "success" || item.status === "partial") && item.data) {
423
+ return {
424
+ target,
425
+ financials: mapCloudFinancials(item.data),
426
+ };
427
+ }
428
+ return {
429
+ target,
430
+ financials: null,
431
+ error: mapBatchError(item, `Cloud financials are unavailable for ${target.symbol}`),
432
+ };
433
+ });
434
+ }, "Cloud financials are unavailable");
435
+ }
436
+
382
437
  async getQuote(ticker: string, exchange = "", _context?: MarketDataRequestContext): Promise<Quote> {
383
438
  await requireVerifiedSession();
384
439
  return withCloudFallback(
@@ -390,6 +445,40 @@ export class GloomberbCloudProvider implements AssetDataProvider {
390
445
  );
391
446
  }
392
447
 
448
+ async getQuotesBatch(
449
+ targets: QuoteSubscriptionTarget[],
450
+ options: { forceRefresh?: boolean } = {},
451
+ ): Promise<QuoteBatchResult[]> {
452
+ await requireVerifiedSession();
453
+ return withCloudFallback(async () => {
454
+ const response = await apiClient.getCloudQuotesBatch(
455
+ targets.map((target) => ({
456
+ symbol: target.symbol,
457
+ exchange: target.exchange,
458
+ })),
459
+ options.forceRefresh ? "refresh" : "cache-first",
460
+ );
461
+ const payload = unwrapRequiredCloudResponse(response, "Cloud quotes are unavailable");
462
+ return payload.items.map((item, index) => {
463
+ const target = targets[index] ?? {
464
+ symbol: item.symbol,
465
+ exchange: item.exchange,
466
+ };
467
+ if ((item.status === "success" || item.status === "partial") && item.data) {
468
+ return {
469
+ target,
470
+ quote: mapQuote(item.data),
471
+ };
472
+ }
473
+ return {
474
+ target,
475
+ quote: null,
476
+ error: mapBatchError(item, `Cloud quotes are unavailable for ${target.symbol}`),
477
+ };
478
+ });
479
+ }, "Cloud quotes are unavailable");
480
+ }
481
+
393
482
  async getExchangeRate(fromCurrency: string): Promise<number> {
394
483
  await requireVerifiedSession();
395
484
  const response = await apiClient.getCloudExchangeRate(fromCurrency);
@@ -575,6 +664,13 @@ export function createGloomberbCloudCapabilities(provider = createGloomberbCloud
575
664
  );
576
665
  return response.items.map((item) => mapCloudNewsArticle(item, query.ticker));
577
666
  },
667
+ async fetchNewsStory(storyId: string): Promise<NewsArticle | null> {
668
+ const story = await withCloudFallback(
669
+ () => apiClient.getCloudNewsStory(storyId),
670
+ "Cloud news story is unavailable",
671
+ );
672
+ return mapCloudNewsArticle(story);
673
+ },
578
674
  },
579
675
  }),
580
676
  ];
@@ -195,6 +195,128 @@ describe("AssetDataRouter", () => {
195
195
  expect(holders.holders[0]?.name).toBe("Vanguard Group Inc");
196
196
  });
197
197
 
198
+ test("falls back to later analyst providers when rating targets are missing", async () => {
199
+ const cloudProvider: DataProvider = {
200
+ ...fallbackProvider,
201
+ id: "cloud",
202
+ name: "Cloud",
203
+ priority: 100,
204
+ async getAnalystResearch(symbol) {
205
+ return {
206
+ providerId: "cloud",
207
+ symbol,
208
+ ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
209
+ recommendations: [],
210
+ earningsEstimates: [],
211
+ revenueEstimates: [],
212
+ };
213
+ },
214
+ };
215
+ const yahooProvider: DataProvider = {
216
+ ...fallbackProvider,
217
+ id: "yahoo",
218
+ name: "Yahoo",
219
+ priority: 1000,
220
+ async getAnalystResearch(symbol) {
221
+ return {
222
+ providerId: "yahoo",
223
+ symbol,
224
+ ratings: [{
225
+ date: "2026-05-01",
226
+ firm: "Yahoo Firm",
227
+ action: "Raises",
228
+ current: "Buy",
229
+ prior: "Buy",
230
+ currentPriceTarget: 680,
231
+ priorPriceTarget: 595,
232
+ }],
233
+ recommendations: [],
234
+ earningsEstimates: [],
235
+ revenueEstimates: [],
236
+ };
237
+ },
238
+ };
239
+
240
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
241
+ const research = await router.getAnalystResearch("AAPL", "NASDAQ");
242
+
243
+ expect(research.providerId).toBe("yahoo");
244
+ expect(research.ratings[0]?.currentPriceTarget).toBe(680);
245
+ expect(research.ratings[0]?.priorPriceTarget).toBe(595);
246
+ });
247
+
248
+ test("prefers cached analyst records with rating targets", async () => {
249
+ const dbPath = createTempDbPath("cached-analyst-targets");
250
+ const persistence = new AppPersistence(dbPath);
251
+ const cloudProvider: DataProvider = {
252
+ ...fallbackProvider,
253
+ id: "cloud",
254
+ name: "Cloud",
255
+ priority: 100,
256
+ async getAnalystResearch(symbol) {
257
+ return {
258
+ providerId: "cloud",
259
+ symbol,
260
+ ratings: [{ date: "2026-05-01", firm: "Cloud Firm", action: "Raises", current: "Buy", prior: "Buy" }],
261
+ recommendations: [],
262
+ earningsEstimates: [],
263
+ revenueEstimates: [],
264
+ };
265
+ },
266
+ };
267
+ const yahooProvider: DataProvider = {
268
+ ...fallbackProvider,
269
+ id: "yahoo",
270
+ name: "Yahoo",
271
+ priority: 1000,
272
+ async getAnalystResearch(symbol) {
273
+ return {
274
+ providerId: "yahoo",
275
+ symbol,
276
+ ratings: [{
277
+ date: "2026-05-01",
278
+ firm: "Yahoo Firm",
279
+ action: "Raises",
280
+ current: "Buy",
281
+ prior: "Buy",
282
+ currentPriceTarget: 680,
283
+ priorPriceTarget: 595,
284
+ }],
285
+ recommendations: [],
286
+ earningsEstimates: [],
287
+ revenueEstimates: [],
288
+ };
289
+ },
290
+ };
291
+ const unavailableProvider = (id: string, priority: number): DataProvider => ({
292
+ ...fallbackProvider,
293
+ id,
294
+ name: id,
295
+ priority,
296
+ async getAnalystResearch() {
297
+ throw new Error(`${id} should not be fetched`);
298
+ },
299
+ });
300
+
301
+ try {
302
+ const seedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources);
303
+ await seedRouter.getAnalystResearch("AAPL", "NASDAQ");
304
+
305
+ const cachedRouter = new AssetDataRouter(
306
+ unavailableProvider("yahoo", 1000),
307
+ [unavailableProvider("cloud", 100)],
308
+ persistence.resources,
309
+ );
310
+ const research = await cachedRouter.getAnalystResearch("AAPL", "NASDAQ");
311
+
312
+ expect(research.providerId).toBe("yahoo");
313
+ expect(research.ratings[0]?.currentPriceTarget).toBe(680);
314
+ expect(research.ratings[0]?.priorPriceTarget).toBe(595);
315
+ } finally {
316
+ persistence.close();
317
+ }
318
+ });
319
+
198
320
  test("prefers broker quotes over fallback quotes", async () => {
199
321
  const router = new AssetDataRouter(fallbackProvider);
200
322
  const broker: BrokerAdapter = {