gloomberb 0.3.7 → 0.4.0

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 (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,4 +1,9 @@
1
1
  import type { TimeRange } from "../components/chart/chart-types";
2
+ import {
3
+ normalizeChartResolutionSupport,
4
+ type ChartResolutionSupport,
5
+ type ManualChartResolution,
6
+ } from "../components/chart/chart-resolution";
2
7
  import type { DataProvider, MarketDataRequestContext, NewsItem, QuoteSubscriptionTarget, SearchRequestContext, SecFilingItem } from "../types/data-provider";
3
8
  import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
4
9
  import type { InstrumentSearchResult } from "../types/instrument";
@@ -14,6 +19,17 @@ import { normalizePriceValueByDivisor, resolveCurrencyUnit } from "../utils/curr
14
19
  import { createProviderMiss } from "./provider-errors";
15
20
 
16
21
  const providerId = "gloomberb-cloud" as const;
22
+ const CLOUD_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
23
+ { resolution: "1m", maxRange: "1W" },
24
+ { resolution: "5m", maxRange: "1M" },
25
+ { resolution: "15m", maxRange: "3M" },
26
+ { resolution: "30m", maxRange: "6M" },
27
+ { resolution: "45m", maxRange: "6M" },
28
+ { resolution: "1h", maxRange: "1Y" },
29
+ { resolution: "1d", maxRange: "ALL" },
30
+ { resolution: "1wk", maxRange: "ALL" },
31
+ { resolution: "1mo", maxRange: "ALL" },
32
+ ]);
17
33
  const CLOUD_PROVIDER_MISS_PATTERNS = [
18
34
  /data not found/i,
19
35
  /symbol.*missing or invalid/i,
@@ -39,6 +55,8 @@ async function withCloudFallback<T>(load: () => Promise<T>, message: string): Pr
39
55
 
40
56
  function mapQuote(quote: CloudQuotePayload): Quote {
41
57
  const { currency, divisor } = resolveCurrencyUnit(quote.currency);
58
+ const listingExchangeName = quote.listingExchangeName ?? quote.exchangeName;
59
+ const listingExchangeFullName = quote.listingExchangeFullName ?? quote.fullExchangeName ?? listingExchangeName;
42
60
  return {
43
61
  ...quote,
44
62
  currency: currency || quote.currency,
@@ -57,6 +75,10 @@ function mapQuote(quote: CloudQuotePayload): Quote {
57
75
  preMarketChange: normalizePriceValueByDivisor(quote.preMarketChange, divisor),
58
76
  postMarketPrice: normalizePriceValueByDivisor(quote.postMarketPrice, divisor),
59
77
  postMarketChange: normalizePriceValueByDivisor(quote.postMarketChange, divisor),
78
+ listingExchangeName,
79
+ listingExchangeFullName,
80
+ exchangeName: listingExchangeName,
81
+ fullExchangeName: listingExchangeFullName,
60
82
  providerId,
61
83
  };
62
84
  }
@@ -92,7 +114,7 @@ function toCloudInterval(interval: string): string {
92
114
  return "45min";
93
115
  case "1d":
94
116
  return "1day";
95
- case "1w":
117
+ case "1wk":
96
118
  return "1week";
97
119
  case "1mo":
98
120
  return "1month";
@@ -118,12 +140,45 @@ function formatCloudDateTime(date: Date, includeTime: boolean): string {
118
140
  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
119
141
  }
120
142
 
143
+ function getRangeStartDate(range: TimeRange, endDate = new Date()): Date {
144
+ const startDate = new Date(endDate);
145
+ switch (range) {
146
+ case "1D":
147
+ startDate.setDate(startDate.getDate() - 1);
148
+ break;
149
+ case "1W":
150
+ startDate.setDate(startDate.getDate() - 7);
151
+ break;
152
+ case "1M":
153
+ startDate.setMonth(startDate.getMonth() - 1);
154
+ break;
155
+ case "3M":
156
+ startDate.setMonth(startDate.getMonth() - 3);
157
+ break;
158
+ case "6M":
159
+ startDate.setMonth(startDate.getMonth() - 6);
160
+ break;
161
+ case "1Y":
162
+ startDate.setFullYear(startDate.getFullYear() - 1);
163
+ break;
164
+ case "5Y":
165
+ startDate.setFullYear(startDate.getFullYear() - 5);
166
+ break;
167
+ case "ALL":
168
+ startDate.setFullYear(startDate.getFullYear() - 20);
169
+ break;
170
+ }
171
+ return startDate;
172
+ }
173
+
121
174
  function toHistoryRequest(range: TimeRange): {
122
175
  interval: string;
123
176
  outputsize: number;
124
177
  rangeKey: TimeRange;
125
178
  } {
126
179
  switch (range) {
180
+ case "1D":
181
+ return { interval: "5min", outputsize: 24 * 12, rangeKey: range };
127
182
  case "1W":
128
183
  return { interval: "1h", outputsize: 7 * 24, rangeKey: range };
129
184
  case "1M":
@@ -179,6 +234,14 @@ export class GloomberbCloudProvider implements DataProvider {
179
234
  readonly name = "Gloomberb Cloud";
180
235
  readonly priority = 100;
181
236
 
237
+ getChartResolutionSupport(): ChartResolutionSupport[] {
238
+ return CLOUD_RESOLUTION_SUPPORT;
239
+ }
240
+
241
+ getChartResolutionCapabilities(): ManualChartResolution[] {
242
+ return CLOUD_RESOLUTION_SUPPORT.map((entry) => entry.resolution);
243
+ }
244
+
182
245
  async canProvide(): Promise<boolean> {
183
246
  return !!(await apiClient.ensureVerifiedSession());
184
247
  }
@@ -260,6 +323,31 @@ export class GloomberbCloudProvider implements DataProvider {
260
323
  .map((point) => mapPricePoint(point, divisor));
261
324
  }
262
325
 
326
+ async getPriceHistoryForResolution(
327
+ ticker: string,
328
+ exchange: string,
329
+ bufferRange: TimeRange,
330
+ resolution: ManualChartResolution,
331
+ _context?: MarketDataRequestContext,
332
+ ): Promise<PricePoint[]> {
333
+ await requireVerifiedSession();
334
+ const interval = toCloudInterval(resolution);
335
+ const endDate = new Date();
336
+ const startDate = getRangeStartDate(bufferRange, endDate);
337
+ const includeTime = /(min|h)$/i.test(interval);
338
+ const response = await withCloudFallback(
339
+ () => apiClient.getCloudHistory(ticker, exchange, {
340
+ interval,
341
+ startDate: formatCloudDateTime(startDate, includeTime),
342
+ endDate: formatCloudDateTime(endDate, includeTime),
343
+ }),
344
+ `Cloud chart data is unavailable for ${ticker}`,
345
+ );
346
+ const { divisor } = resolveCurrencyUnit(response.providerMeta?.currency);
347
+ return unwrapRequiredCloudResponse(response, `Cloud chart data is unavailable for ${ticker}`)
348
+ .map((point) => mapPricePoint(point, divisor));
349
+ }
350
+
263
351
  async getDetailedPriceHistory(
264
352
  ticker: string,
265
353
  exchange: string,
@@ -632,7 +632,7 @@ describe("ProviderRouter", () => {
632
632
  persistence.close();
633
633
  });
634
634
 
635
- test("prefers fallback price data when broker and provider differ by a 100x unit mismatch", async () => {
635
+ test("keeps broker price authority when a fallback quote is off by a likely 100x unit mismatch", async () => {
636
636
  const router = new ProviderRouter({
637
637
  ...fallbackProvider,
638
638
  async getTickerFinancials() {
@@ -642,11 +642,13 @@ describe("ProviderRouter", () => {
642
642
  priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 0.245 }],
643
643
  quote: {
644
644
  symbol: "IQE.L",
645
+ providerId: "yahoo",
645
646
  price: 0.245,
646
647
  currency: "GBP",
647
648
  change: -0.021,
648
649
  changePercent: -7.89,
649
650
  lastUpdated: Date.now(),
651
+ dataSource: "yahoo",
650
652
  },
651
653
  fundamentals: { revenue: 1000 },
652
654
  };
@@ -669,11 +671,13 @@ describe("ProviderRouter", () => {
669
671
  priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 24.5 }],
670
672
  quote: {
671
673
  symbol: "IQE",
674
+ providerId: "ibkr",
672
675
  price: 24.5,
673
676
  currency: "GBP",
674
677
  change: -2.1,
675
678
  changePercent: -7.89,
676
679
  lastUpdated: Date.now(),
680
+ dataSource: "live",
677
681
  },
678
682
  fundamentals: { netIncome: 200 },
679
683
  };
@@ -718,7 +722,8 @@ describe("ProviderRouter", () => {
718
722
  brokerInstanceId: "ibkr-work",
719
723
  });
720
724
 
721
- expect(merged.quote?.price).toBe(0.245);
725
+ expect(merged.quote?.price).toBe(24.5);
726
+ expect(merged.quote?.provenance?.rejectedPriceProviders).toContain("yahoo");
722
727
  expect(merged.priceHistory[0]?.close).toBe(0.245);
723
728
  expect(merged.fundamentals?.netIncome).toBe(200);
724
729
  });
@@ -1071,11 +1076,13 @@ describe("ProviderRouter", () => {
1071
1076
  priceHistory: [],
1072
1077
  quote: {
1073
1078
  symbol: "IQE.L",
1079
+ providerId: "yahoo",
1074
1080
  price: 0.245,
1075
1081
  currency: "GBP",
1076
1082
  change: 0,
1077
1083
  changePercent: 0,
1078
1084
  lastUpdated: Date.now(),
1085
+ dataSource: "yahoo",
1079
1086
  },
1080
1087
  };
1081
1088
  },
@@ -1100,11 +1107,13 @@ describe("ProviderRouter", () => {
1100
1107
  priceHistory: [],
1101
1108
  quote: {
1102
1109
  symbol: "IQE",
1110
+ providerId: "ibkr",
1103
1111
  price: 24.5,
1104
1112
  currency: "GBP",
1105
1113
  change: 0,
1106
1114
  changePercent: 0,
1107
1115
  lastUpdated: Date.now(),
1116
+ dataSource: "live",
1108
1117
  },
1109
1118
  };
1110
1119
  },
@@ -1157,7 +1166,7 @@ describe("ProviderRouter", () => {
1157
1166
  brokerId: "ibkr",
1158
1167
  brokerInstanceId: "ibkr-live",
1159
1168
  });
1160
- expect(live.quote?.price).toBe(0.245);
1169
+ expect(live.quote?.price).toBe(24.5);
1161
1170
 
1162
1171
  const flex = await router.getTickerFinancials("IQE", "LSE", {
1163
1172
  brokerId: "ibkr",
@@ -1281,6 +1290,209 @@ describe("ProviderRouter", () => {
1281
1290
  persistence.close();
1282
1291
  });
1283
1292
 
1293
+ test("drops stale cached cloud quotes while preserving cached cloud fundamentals", () => {
1294
+ const dbPath = createTempDbPath("stale-cloud-cached-financials");
1295
+ const persistence = new AppPersistence(dbPath);
1296
+ const now = Date.parse("2026-04-08T10:55:00Z");
1297
+
1298
+ persistence.resources.set(
1299
+ {
1300
+ namespace: "market",
1301
+ kind: "financials",
1302
+ entityKey: "HY9H",
1303
+ variantKey: "exchange=FWB2",
1304
+ sourceKey: "provider:gloomberb-cloud",
1305
+ },
1306
+ {
1307
+ annualStatements: [],
1308
+ quarterlyStatements: [],
1309
+ priceHistory: [],
1310
+ quote: {
1311
+ symbol: "HY9H",
1312
+ price: 528,
1313
+ currency: "EUR",
1314
+ change: 28,
1315
+ changePercent: 5.6,
1316
+ lastUpdated: Date.parse("2026-04-07T19:55:00Z"),
1317
+ marketState: "REGULAR",
1318
+ exchangeName: "FWB2",
1319
+ listingExchangeName: "FWB2",
1320
+ providerId: "gloomberb-cloud",
1321
+ dataSource: "delayed",
1322
+ },
1323
+ fundamentals: {
1324
+ revenue: 1234,
1325
+ },
1326
+ profile: {
1327
+ description: "Cloud profile",
1328
+ },
1329
+ },
1330
+ {
1331
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
1332
+ fetchedAt: now,
1333
+ },
1334
+ );
1335
+ persistence.resources.set(
1336
+ {
1337
+ namespace: "market",
1338
+ kind: "financials",
1339
+ entityKey: "HY9H",
1340
+ variantKey: "exchange=FWB2",
1341
+ sourceKey: "provider:yahoo",
1342
+ },
1343
+ {
1344
+ annualStatements: [],
1345
+ quarterlyStatements: [],
1346
+ priceHistory: [],
1347
+ quote: {
1348
+ symbol: "HY9H.F",
1349
+ price: 596,
1350
+ currency: "EUR",
1351
+ change: 68,
1352
+ changePercent: 12.88,
1353
+ lastUpdated: now,
1354
+ marketState: "REGULAR",
1355
+ exchangeName: "FWB2",
1356
+ listingExchangeName: "FWB2",
1357
+ providerId: "yahoo",
1358
+ dataSource: "yahoo",
1359
+ },
1360
+ },
1361
+ {
1362
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
1363
+ fetchedAt: now,
1364
+ },
1365
+ );
1366
+
1367
+ const router = new ProviderRouter({
1368
+ ...fallbackProvider,
1369
+ id: "yahoo",
1370
+ name: "Yahoo",
1371
+ async getTickerFinancials() {
1372
+ throw new Error("should not fetch financials");
1373
+ },
1374
+ }, [{
1375
+ ...fallbackProvider,
1376
+ id: "gloomberb-cloud",
1377
+ name: "Cloud",
1378
+ priority: 100,
1379
+ async getTickerFinancials() {
1380
+ throw new Error("should not fetch financials");
1381
+ },
1382
+ }], persistence.resources);
1383
+
1384
+ const cached = router.getCachedFinancialsForTargets([{
1385
+ symbol: "HY9H",
1386
+ exchange: "FWB2",
1387
+ }], { allowExpired: true });
1388
+
1389
+ expect(cached.get("HY9H")?.quote?.providerId).toBe("yahoo");
1390
+ expect(cached.get("HY9H")?.quote?.price).toBe(596);
1391
+ expect(cached.get("HY9H")?.fundamentals?.revenue).toBe(1234);
1392
+ expect(cached.get("HY9H")?.profile?.description).toBe("Cloud profile");
1393
+
1394
+ persistence.close();
1395
+ });
1396
+
1397
+ test("drops cached premarket cloud quotes that lack an active-session price", () => {
1398
+ const dbPath = createTempDbPath("stale-cloud-premarket-cache");
1399
+ const persistence = new AppPersistence(dbPath);
1400
+ const now = Date.parse("2026-04-08T10:55:00Z");
1401
+
1402
+ persistence.resources.set(
1403
+ {
1404
+ namespace: "market",
1405
+ kind: "financials",
1406
+ entityKey: "AMD",
1407
+ variantKey: "exchange=NASDAQ",
1408
+ sourceKey: "provider:gloomberb-cloud",
1409
+ },
1410
+ {
1411
+ annualStatements: [],
1412
+ quarterlyStatements: [],
1413
+ priceHistory: [],
1414
+ quote: {
1415
+ symbol: "AMD",
1416
+ price: 221.53,
1417
+ currency: "USD",
1418
+ change: 1.35,
1419
+ changePercent: 0.61,
1420
+ lastUpdated: now,
1421
+ marketState: "PRE",
1422
+ exchangeName: "NASDAQ",
1423
+ listingExchangeName: "NASDAQ",
1424
+ providerId: "gloomberb-cloud",
1425
+ dataSource: "delayed",
1426
+ },
1427
+ },
1428
+ {
1429
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
1430
+ fetchedAt: now,
1431
+ },
1432
+ );
1433
+ persistence.resources.set(
1434
+ {
1435
+ namespace: "market",
1436
+ kind: "financials",
1437
+ entityKey: "AMD",
1438
+ variantKey: "exchange=NASDAQ",
1439
+ sourceKey: "provider:yahoo",
1440
+ },
1441
+ {
1442
+ annualStatements: [],
1443
+ quarterlyStatements: [],
1444
+ priceHistory: [],
1445
+ quote: {
1446
+ symbol: "AMD",
1447
+ price: 221.53,
1448
+ currency: "USD",
1449
+ change: 1.35,
1450
+ changePercent: 0.61,
1451
+ lastUpdated: now,
1452
+ marketState: "PRE",
1453
+ preMarketPrice: 231,
1454
+ preMarketChange: 9.47,
1455
+ preMarketChangePercent: 4.27,
1456
+ exchangeName: "NASDAQ",
1457
+ listingExchangeName: "NASDAQ",
1458
+ providerId: "yahoo",
1459
+ dataSource: "yahoo",
1460
+ },
1461
+ },
1462
+ {
1463
+ cachePolicy: { staleMs: 60_000, expireMs: 60_000 },
1464
+ fetchedAt: now,
1465
+ },
1466
+ );
1467
+
1468
+ const router = new ProviderRouter({
1469
+ ...fallbackProvider,
1470
+ id: "yahoo",
1471
+ name: "Yahoo",
1472
+ async getTickerFinancials() {
1473
+ throw new Error("should not fetch financials");
1474
+ },
1475
+ }, [{
1476
+ ...fallbackProvider,
1477
+ id: "gloomberb-cloud",
1478
+ name: "Cloud",
1479
+ priority: 100,
1480
+ async getTickerFinancials() {
1481
+ throw new Error("should not fetch financials");
1482
+ },
1483
+ }], persistence.resources);
1484
+
1485
+ const cached = router.getCachedFinancialsForTargets([{
1486
+ symbol: "AMD",
1487
+ exchange: "NASDAQ",
1488
+ }], { allowExpired: true });
1489
+
1490
+ expect(cached.get("AMD")?.quote?.providerId).toBe("yahoo");
1491
+ expect(cached.get("AMD")?.quote?.preMarketPrice).toBe(231);
1492
+
1493
+ persistence.close();
1494
+ });
1495
+
1284
1496
  test("does not log expected provider misses for missing chart data", async () => {
1285
1497
  const noisyProvider: DataProvider = {
1286
1498
  ...fallbackProvider,
@@ -1485,6 +1697,56 @@ describe("ProviderRouter", () => {
1485
1697
  persistence.close();
1486
1698
  });
1487
1699
 
1700
+ test("falls back to later providers for fixed-resolution chart history", async () => {
1701
+ const cloudProvider: DataProvider = {
1702
+ ...fallbackProvider,
1703
+ id: "cloud",
1704
+ name: "Cloud",
1705
+ priority: 100,
1706
+ async getPriceHistoryForResolution() {
1707
+ return [];
1708
+ },
1709
+ };
1710
+ const yahooProvider: DataProvider = {
1711
+ ...fallbackProvider,
1712
+ id: "yahoo",
1713
+ name: "Yahoo",
1714
+ priority: 1000,
1715
+ async getPriceHistoryForResolution() {
1716
+ return [{ date: new Date("2026-03-28T00:00:00Z"), close: 102 }];
1717
+ },
1718
+ };
1719
+
1720
+ const router = new ProviderRouter(yahooProvider, [cloudProvider]);
1721
+ const history = await router.getPriceHistoryForResolution("AAPL", "NASDAQ", "1Y", "1d");
1722
+
1723
+ expect(history[0]?.close).toBe(102);
1724
+ });
1725
+
1726
+ test("returns normalized manual chart resolution capabilities", async () => {
1727
+ const cloudProvider: DataProvider = {
1728
+ ...fallbackProvider,
1729
+ id: "cloud",
1730
+ name: "Cloud",
1731
+ priority: 100,
1732
+ async getChartResolutionCapabilities() {
1733
+ return [];
1734
+ },
1735
+ };
1736
+ const yahooProvider: DataProvider = {
1737
+ ...fallbackProvider,
1738
+ id: "yahoo",
1739
+ name: "Yahoo",
1740
+ priority: 1000,
1741
+ async getChartResolutionCapabilities() {
1742
+ return ["1wk", "auto", "1d", "bogus"];
1743
+ },
1744
+ };
1745
+
1746
+ const router = new ProviderRouter(yahooProvider, [cloudProvider]);
1747
+ expect(await router.getChartResolutionCapabilities("AAPL", "NASDAQ")).toEqual(["1d", "1wk"]);
1748
+ });
1749
+
1488
1750
  test("falls back to later providers when detailed chart history is empty", async () => {
1489
1751
  const cloudProvider: DataProvider = {
1490
1752
  ...fallbackProvider,