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
@@ -16,9 +16,22 @@ import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types
16
16
  import type { BrokerContractRef, InstrumentSearchResult } from "../types/instrument";
17
17
  import type { CachePolicy, CachePolicyMap } from "../types/persistence";
18
18
  import type { TimeRange } from "../components/chart/chart-types";
19
+ import {
20
+ isIntradayResolution,
21
+ normalizeChartResolutionSupport,
22
+ type ChartResolutionSupport,
23
+ type ManualChartResolution,
24
+ } from "../components/chart/chart-resolution";
19
25
  import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
20
26
  import { debugLog } from "../utils/debug-log";
21
27
  import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
28
+ import { isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
29
+ import {
30
+ mergeQuoteContributionMaps,
31
+ resolveCanonicalQuote,
32
+ resolveTickerFinancialsQuoteState,
33
+ seedQuoteContributions,
34
+ } from "../utils/quote-resolution";
22
35
  import { isProviderMiss } from "./provider-errors";
23
36
 
24
37
  const providerLog = debugLog.createLogger("provider-router");
@@ -65,6 +78,15 @@ function normalizeExchange(exchange?: string): string {
65
78
  return (exchange ?? "").trim().toUpperCase();
66
79
  }
67
80
 
81
+ function sanitizeCachedFinancials(financials: TickerFinancials): TickerFinancials {
82
+ if (!isQuoteStaleForCurrentSession(financials.quote)) return financials;
83
+ return {
84
+ ...financials,
85
+ quote: undefined,
86
+ quoteContributions: undefined,
87
+ };
88
+ }
89
+
68
90
  function compactUrl(url: string): string {
69
91
  return url.trim();
70
92
  }
@@ -106,7 +128,7 @@ function buildVariantKey(parts: Array<[string, string | number | undefined | nul
106
128
  }
107
129
 
108
130
  function isIntradayRange(range: TimeRange): boolean {
109
- return range === "1W" || range === "1M" || range === "3M";
131
+ return range === "1D" || range === "1W" || range === "1M" || range === "3M";
110
132
  }
111
133
 
112
134
  function hasMeaningfulFundamentals(data: TickerFinancials | null | undefined): boolean {
@@ -144,17 +166,23 @@ function mergeDefinedObject<T extends object>(preferred: T | null | undefined, f
144
166
  function mergeFinancials(primary: TickerFinancials | null, fallback: TickerFinancials | null): TickerFinancials | null {
145
167
  if (!primary || !fallback) {
146
168
  const single = primary ?? fallback;
147
- return single ? normalizeTickerFinancialsPriceHistory(single) : null;
169
+ return single ? resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(single)) : null;
148
170
  }
149
171
 
150
172
  const preferFallbackPriceData = hasLikelyPriceUnitMismatch(primary, fallback);
151
173
  const dominant = preferFallbackPriceData ? fallback : primary;
152
174
  const secondary = preferFallbackPriceData ? primary : fallback;
175
+ const quoteContributions = mergeQuoteContributionMaps(
176
+ seedQuoteContributions(primary),
177
+ seedQuoteContributions(fallback),
178
+ );
179
+ const resolvedQuote = resolveCanonicalQuote(quoteContributions).quote;
153
180
 
154
181
  return {
155
182
  ...fallback,
156
183
  ...primary,
157
- quote: mergeDefinedObject(dominant.quote, secondary.quote),
184
+ quote: resolvedQuote,
185
+ quoteContributions,
158
186
  profile: mergeDefinedObject(primary.profile, fallback.profile),
159
187
  fundamentals: mergeDefinedObject(primary.fundamentals, fallback.fundamentals),
160
188
  priceHistory: normalizePriceHistory(dominant.priceHistory.length > 0 ? dominant.priceHistory : secondary.priceHistory),
@@ -174,7 +202,7 @@ function mergeCachedFinancialRecords(records: CachedResourceRecord<TickerFinanci
174
202
  for (const record of records) {
175
203
  if (seenSources.has(record.sourceKey)) continue;
176
204
  seenSources.add(record.sourceKey);
177
- merged = mergeFinancials(merged, record.value);
205
+ merged = mergeFinancials(merged, sanitizeCachedFinancials(record.value));
178
206
  stale = stale || record.stale;
179
207
  }
180
208
 
@@ -299,7 +327,10 @@ export class ProviderRouter implements DataProvider {
299
327
  ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
300
328
  ...this.getProviderSourceKeys(),
301
329
  ];
302
- const cached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
330
+ const rawCached = this.selectCachedResource<Quote>("quote", entityKey, variantKeys, sourceKeys, false);
331
+ const cached = rawCached && !isQuoteStaleForCurrentSession(rawCached.value)
332
+ ? rawCached
333
+ : null;
303
334
  const forceRefresh = context?.cacheMode === "refresh";
304
335
  if (cached && !forceRefresh && !cached.stale) {
305
336
  return cached.value;
@@ -503,6 +534,65 @@ export class ProviderRouter implements DataProvider {
503
534
  return providerHistory.value;
504
535
  }
505
536
 
537
+ async getPriceHistoryForResolution(
538
+ ticker: string,
539
+ exchange: string,
540
+ bufferRange: TimeRange,
541
+ resolution: ManualChartResolution,
542
+ context?: MarketDataRequestContext,
543
+ ): Promise<PricePoint[]> {
544
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
545
+ const variantKeys = [
546
+ buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", bufferRange], ["resolution", resolution]]),
547
+ buildVariantKey([["range", bufferRange], ["resolution", resolution]]),
548
+ ];
549
+ const sourceKeys = [
550
+ ...this.getBrokerCandidatesForContext(context, false).map((candidate) => this.brokerSourceKey(candidate)),
551
+ ...this.getProviderSourceKeys(),
552
+ ];
553
+ const cached = this.selectCachedArrayResource<PricePoint>("price-history", entityKey, variantKeys, sourceKeys, false);
554
+ const cachedValue = cached ? normalizePriceHistory(cached.value) : [];
555
+ const forceRefresh = context?.cacheMode === "refresh";
556
+ if (cachedValue.length > 0 && !forceRefresh && cached && !cached.stale) {
557
+ return cachedValue;
558
+ }
559
+
560
+ const brokerHistory = await withBrokerTimeout(this.fetchBrokerPriceHistoryForResolution(ticker, exchange, bufferRange, resolution, context));
561
+ if (brokerHistory && brokerHistory.value.length > 0) return brokerHistory.value;
562
+
563
+ const providerHistory = await this.fetchProviderPriceHistoryForResolution(ticker, exchange, bufferRange, resolution, context);
564
+ if (providerHistory && providerHistory.value.length > 0) {
565
+ return providerHistory.value;
566
+ }
567
+ if (cachedValue.length > 0) return cachedValue;
568
+ if (!providerHistory) {
569
+ throw new Error(`No resolution-aware history provider available for ${ticker}`);
570
+ }
571
+ return providerHistory.value;
572
+ }
573
+
574
+ async getChartResolutionSupport(
575
+ ticker: string,
576
+ exchange?: string,
577
+ context?: MarketDataRequestContext,
578
+ ): Promise<ChartResolutionSupport[]> {
579
+ const brokerSupport = await withBrokerTimeout(this.fetchBrokerChartResolutionSupport(ticker, exchange, context));
580
+ if (brokerSupport && brokerSupport.value.length > 0) {
581
+ return brokerSupport.value;
582
+ }
583
+ const providerSupport = await this.fetchProviderChartResolutionSupport(ticker, exchange, context);
584
+ return providerSupport?.value ?? [];
585
+ }
586
+
587
+ async getChartResolutionCapabilities(
588
+ ticker: string,
589
+ exchange?: string,
590
+ context?: MarketDataRequestContext,
591
+ ): Promise<ManualChartResolution[]> {
592
+ const support = await this.getChartResolutionSupport(ticker, exchange, context);
593
+ return support.map((entry) => entry.resolution);
594
+ }
595
+
506
596
  async getDetailedPriceHistory(
507
597
  ticker: string,
508
598
  exchange: string,
@@ -717,6 +807,9 @@ export class ProviderRouter implements DataProvider {
717
807
  const brokerRecord = brokerSourceKeys.length > 0
718
808
  ? this.selectCachedResource<TickerFinancials>("financials", entityKey, variantKeys, brokerSourceKeys, allowExpired)
719
809
  : null;
810
+ const sanitizedBrokerRecord = brokerRecord
811
+ ? { ...brokerRecord, value: sanitizeCachedFinancials(brokerRecord.value) }
812
+ : null;
720
813
  const providerSelection = mergeCachedFinancialRecords(
721
814
  this.listCachedResources<TickerFinancials>(
722
815
  "financials",
@@ -727,10 +820,10 @@ export class ProviderRouter implements DataProvider {
727
820
  ),
728
821
  );
729
822
  return {
730
- brokerRecord,
823
+ brokerRecord: sanitizedBrokerRecord,
731
824
  providerValue: providerSelection.value,
732
- value: mergeFinancials(brokerRecord?.value ?? null, providerSelection.value),
733
- stale: (brokerRecord?.stale ?? false) || providerSelection.stale,
825
+ value: mergeFinancials(sanitizedBrokerRecord?.value ?? null, providerSelection.value),
826
+ stale: (sanitizedBrokerRecord?.stale ?? false) || providerSelection.stale,
734
827
  };
735
828
  }
736
829
 
@@ -880,12 +973,14 @@ export class ProviderRouter implements DataProvider {
880
973
  for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
881
974
  if (!candidate.broker.getTickerFinancials) continue;
882
975
  try {
883
- const result = normalizeTickerFinancialsPriceHistory(await candidate.broker.getTickerFinancials(
976
+ const rawResult = await candidate.broker.getTickerFinancials(
884
977
  ticker,
885
978
  candidate.instance,
886
979
  exchange,
887
980
  context?.instrument ?? null,
888
- ));
981
+ );
982
+ const result = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawResult));
983
+ if (!result) continue;
889
984
  this.cacheResource(
890
985
  "financials",
891
986
  entityKey,
@@ -914,7 +1009,9 @@ export class ProviderRouter implements DataProvider {
914
1009
 
915
1010
  for (const provider of this.providersInPriorityOrder()) {
916
1011
  try {
917
- const value = normalizeTickerFinancialsPriceHistory(await provider.getTickerFinancials(ticker, exchange, context));
1012
+ const rawValue = await provider.getTickerFinancials(ticker, exchange, context);
1013
+ const value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawValue));
1014
+ if (!value) continue;
918
1015
  const sourceKey = this.providerSourceKey(provider);
919
1016
  this.cacheResource(
920
1017
  "financials",
@@ -1020,6 +1117,42 @@ export class ProviderRouter implements DataProvider {
1020
1117
  return null;
1021
1118
  }
1022
1119
 
1120
+ private async fetchBrokerPriceHistoryForResolution(
1121
+ ticker: string,
1122
+ exchange: string,
1123
+ bufferRange: TimeRange,
1124
+ resolution: ManualChartResolution,
1125
+ context?: MarketDataRequestContext,
1126
+ ): Promise<SourceResult<PricePoint[]> | null> {
1127
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1128
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", bufferRange], ["resolution", resolution]]);
1129
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
1130
+ if (!candidate.broker.getPriceHistoryForResolution) continue;
1131
+ try {
1132
+ const result = normalizePriceHistory(await candidate.broker.getPriceHistoryForResolution(
1133
+ ticker,
1134
+ candidate.instance,
1135
+ exchange,
1136
+ bufferRange,
1137
+ resolution,
1138
+ context?.instrument ?? null,
1139
+ ));
1140
+ this.cacheResource(
1141
+ "price-history",
1142
+ entityKey,
1143
+ variantKey,
1144
+ this.brokerSourceKey(candidate),
1145
+ result,
1146
+ this.resolveBrokerPolicy(isIntradayResolution(resolution) ? "priceHistoryIntraday" : "priceHistoryDaily", candidate.broker),
1147
+ );
1148
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
1149
+ } catch {
1150
+ // continue
1151
+ }
1152
+ }
1153
+ return null;
1154
+ }
1155
+
1023
1156
  private async fetchProviderPriceHistory(
1024
1157
  ticker: string,
1025
1158
  exchange: string,
@@ -1056,6 +1189,101 @@ export class ProviderRouter implements DataProvider {
1056
1189
  return firstEmptyResult;
1057
1190
  }
1058
1191
 
1192
+ private async fetchProviderPriceHistoryForResolution(
1193
+ ticker: string,
1194
+ exchange: string,
1195
+ bufferRange: TimeRange,
1196
+ resolution: ManualChartResolution,
1197
+ context?: MarketDataRequestContext,
1198
+ ): Promise<SourceResult<PricePoint[]> | null> {
1199
+ const entityKey = this.getEntityKey(ticker, exchange, context?.instrument);
1200
+ const variantKey = buildVariantKey([["exchange", normalizeExchange(exchange)], ["range", bufferRange], ["resolution", resolution]]);
1201
+ let firstEmptyResult: SourceResult<PricePoint[]> | null = null;
1202
+
1203
+ for (const provider of this.providersInPriorityOrder()) {
1204
+ if (!provider.getPriceHistoryForResolution) continue;
1205
+ try {
1206
+ const value = normalizePriceHistory(await provider.getPriceHistoryForResolution(ticker, exchange, bufferRange, resolution, context));
1207
+ const sourceKey = this.providerSourceKey(provider);
1208
+ this.cacheResource(
1209
+ "price-history",
1210
+ entityKey,
1211
+ variantKey,
1212
+ sourceKey,
1213
+ value,
1214
+ this.resolveProviderPolicy(isIntradayResolution(resolution) ? "priceHistoryIntraday" : "priceHistoryDaily", provider),
1215
+ );
1216
+ if (value.length > 0) {
1217
+ return { sourceKey, value };
1218
+ }
1219
+ firstEmptyResult ??= { sourceKey, value };
1220
+ } catch (error) {
1221
+ if (shouldLogProviderError(error)) {
1222
+ providerLog.error(`${provider.id} failed: ${error}`);
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ return firstEmptyResult;
1228
+ }
1229
+
1230
+ private async fetchBrokerChartResolutionSupport(
1231
+ ticker: string,
1232
+ exchange?: string,
1233
+ context?: MarketDataRequestContext,
1234
+ ): Promise<SourceResult<ChartResolutionSupport[]> | null> {
1235
+ for (const candidate of this.getBrokerCandidatesForContext(context, false)) {
1236
+ if (!candidate.broker.getChartResolutionSupport && !candidate.broker.getChartResolutionCapabilities) continue;
1237
+ try {
1238
+ const result = candidate.broker.getChartResolutionSupport
1239
+ ? normalizeChartResolutionSupport(await candidate.broker.getChartResolutionSupport(
1240
+ ticker,
1241
+ candidate.instance,
1242
+ exchange,
1243
+ context?.instrument ?? null,
1244
+ ))
1245
+ : normalizeChartResolutionSupport(
1246
+ (await candidate.broker.getChartResolutionCapabilities!(
1247
+ ticker,
1248
+ candidate.instance,
1249
+ exchange,
1250
+ context?.instrument ?? null,
1251
+ )).map((resolution) => ({ resolution, maxRange: "ALL" })),
1252
+ );
1253
+ if (result.length === 0) continue;
1254
+ return { sourceKey: this.brokerSourceKey(candidate), value: result };
1255
+ } catch {
1256
+ // continue
1257
+ }
1258
+ }
1259
+ return null;
1260
+ }
1261
+
1262
+ private async fetchProviderChartResolutionSupport(
1263
+ ticker: string,
1264
+ exchange?: string,
1265
+ context?: MarketDataRequestContext,
1266
+ ): Promise<SourceResult<ChartResolutionSupport[]> | null> {
1267
+ for (const provider of this.providersInPriorityOrder()) {
1268
+ if (!provider.getChartResolutionSupport && !provider.getChartResolutionCapabilities) continue;
1269
+ try {
1270
+ const result = provider.getChartResolutionSupport
1271
+ ? normalizeChartResolutionSupport(await provider.getChartResolutionSupport(ticker, exchange, context))
1272
+ : normalizeChartResolutionSupport(
1273
+ (await provider.getChartResolutionCapabilities!(ticker, exchange, context)).map((resolution) => ({ resolution, maxRange: "ALL" })),
1274
+ );
1275
+ if (result.length === 0) continue;
1276
+ return { sourceKey: this.providerSourceKey(provider), value: result };
1277
+ } catch (error) {
1278
+ if (shouldLogProviderError(error)) {
1279
+ providerLog.error(`${provider.id} failed: ${error}`);
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ return null;
1285
+ }
1286
+
1059
1287
  private async fetchBrokerDetailedPriceHistory(
1060
1288
  ticker: string,
1061
1289
  exchange: string,
@@ -11,4 +11,30 @@ describe("YahooFinanceClient exchange aliases", () => {
11
11
  const provider = new YahooFinanceClient() as any;
12
12
  expect(provider.getSymbolsToTry("HY9H", "FWB2")).toEqual(["HY9H.F", "HY9H.DE"]);
13
13
  });
14
+
15
+ test("reports its manual chart resolution capabilities", () => {
16
+ const provider = new YahooFinanceClient();
17
+ expect(provider.getChartResolutionCapabilities()).toEqual(["5m", "15m", "1h", "1d", "1wk", "1mo"]);
18
+ });
19
+
20
+ test("maps manual resolution requests to yahoo chart range plus interval", async () => {
21
+ const provider = new YahooFinanceClient() as any;
22
+ let request: { symbol: string; range: string; interval: string } | null = null;
23
+ provider.fetchChart = async (symbol: string, range: string, interval: string) => {
24
+ request = { symbol, range, interval };
25
+ return {
26
+ meta: { currency: "USD" },
27
+ history: [{ date: new Date("2026-03-30T00:00:00Z"), close: 200 }],
28
+ };
29
+ };
30
+
31
+ const history = await provider.getPriceHistoryForResolution("AAPL", "NASDAQ", "1Y", "1wk");
32
+
33
+ expect(request).toEqual({
34
+ symbol: "AAPL",
35
+ range: "1y",
36
+ interval: "1wk",
37
+ });
38
+ expect(history[0]?.close).toBe(200);
39
+ });
14
40
  });
@@ -1,6 +1,11 @@
1
1
  import type { Quote, Fundamentals, FinancialStatement, PricePoint, TickerFinancials, MarketState, OptionContract, OptionsChain, CompanyProfile } from "../types/financials";
2
2
  import type { DataProvider, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
3
3
  import type { TimeRange } from "../components/chart/chart-types";
4
+ import {
5
+ normalizeChartResolutionSupport,
6
+ type ChartResolutionSupport,
7
+ type ManualChartResolution,
8
+ } from "../components/chart/chart-resolution";
4
9
  import type { InstrumentSearchResult } from "../types/instrument";
5
10
  import { SecEdgarClient } from "./sec-edgar";
6
11
 
@@ -272,6 +277,7 @@ const INTRADAY_HISTORY_TTL = 5 * 60_000; // 5 min for intraday ranges
272
277
 
273
278
  // Maps TimeRange → Yahoo API { range, interval } for optimal granularity
274
279
  const RANGE_PARAMS: Record<TimeRange, { range: string; interval: string; ttl: number }> = {
280
+ "1D": { range: "1d", interval: "5m", ttl: INTRADAY_HISTORY_TTL },
275
281
  "1W": { range: "5d", interval: "5m", ttl: INTRADAY_HISTORY_TTL },
276
282
  "1M": { range: "1mo", interval: "15m", ttl: INTRADAY_HISTORY_TTL },
277
283
  "3M": { range: "3mo", interval: "1h", ttl: QUOTE_TTL },
@@ -281,6 +287,15 @@ const RANGE_PARAMS: Record<TimeRange, { range: string; interval: string; ttl: nu
281
287
  "ALL": { range: "max", interval: "1wk", ttl: HISTORY_TTL },
282
288
  };
283
289
 
290
+ const YAHOO_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
291
+ { resolution: "5m", maxRange: "1W" },
292
+ { resolution: "15m", maxRange: "1M" },
293
+ { resolution: "1h", maxRange: "3M" },
294
+ { resolution: "1d", maxRange: "5Y" },
295
+ { resolution: "1wk", maxRange: "ALL" },
296
+ { resolution: "1mo", maxRange: "ALL" },
297
+ ]);
298
+
284
299
  export class YahooFinanceClient implements DataProvider {
285
300
  readonly id = "yahoo";
286
301
  readonly name = "Yahoo Finance";
@@ -660,6 +675,7 @@ export class YahooFinanceClient implements DataProvider {
660
675
 
661
676
  const quote: Quote = {
662
677
  symbol,
678
+ providerId: this.id,
663
679
  price: currentPrice,
664
680
  currency: normalizedCurrency,
665
681
  change,
@@ -671,7 +687,10 @@ export class YahooFinanceClient implements DataProvider {
671
687
  lastUpdated: Date.now(),
672
688
  exchangeName: meta.exchangeName,
673
689
  fullExchangeName: meta.fullExchangeName,
690
+ listingExchangeName: meta.exchangeName,
691
+ listingExchangeFullName: meta.fullExchangeName,
674
692
  marketState,
693
+ sessionConfidence: "derived",
675
694
  dataSource: "yahoo",
676
695
  ...quoteSupplement,
677
696
  ...extHours,
@@ -799,6 +818,7 @@ export class YahooFinanceClient implements DataProvider {
799
818
 
800
819
  const quote: Quote = {
801
820
  symbol,
821
+ providerId: this.id,
802
822
  price,
803
823
  currency: normalizedCurrency,
804
824
  change,
@@ -809,7 +829,10 @@ export class YahooFinanceClient implements DataProvider {
809
829
  lastUpdated: Date.now(),
810
830
  exchangeName: meta.exchangeName,
811
831
  fullExchangeName: meta.fullExchangeName,
832
+ listingExchangeName: meta.exchangeName,
833
+ listingExchangeFullName: meta.fullExchangeName,
812
834
  marketState,
835
+ sessionConfidence: "derived",
813
836
  dataSource: "yahoo",
814
837
  ...quoteSupplement,
815
838
  ...extHours,
@@ -925,14 +948,26 @@ export class YahooFinanceClient implements DataProvider {
925
948
  }
926
949
 
927
950
  /** Fetch price history with appropriate granularity for the given time range */
928
- async getPriceHistory(ticker: string, exchange = "", range: TimeRange, _context?: MarketDataRequestContext): Promise<PricePoint[]> {
929
- const params = RANGE_PARAMS[range];
951
+ getChartResolutionSupport(): ChartResolutionSupport[] {
952
+ return YAHOO_RESOLUTION_SUPPORT;
953
+ }
954
+
955
+ getChartResolutionCapabilities(): ManualChartResolution[] {
956
+ return YAHOO_RESOLUTION_SUPPORT.map((entry) => entry.resolution);
957
+ }
958
+
959
+ private async loadPriceHistory(
960
+ ticker: string,
961
+ exchange: string,
962
+ range: string,
963
+ interval: ManualChartResolution,
964
+ ): Promise<PricePoint[]> {
930
965
  const symbolsToTry = this.getSymbolsToTry(ticker, exchange);
931
966
  let lastError: any;
932
967
 
933
968
  for (const symbol of symbolsToTry) {
934
969
  try {
935
- const { meta, history } = await this.fetchChart(symbol, params.range, params.interval);
970
+ const { meta, history } = await this.fetchChart(symbol, range, interval);
936
971
 
937
972
  // Normalize sub-unit currencies in price history
938
973
  const { divisor } = normalizeSubUnitCurrency(meta.currency || "USD");
@@ -953,6 +988,22 @@ export class YahooFinanceClient implements DataProvider {
953
988
  throw lastError || new Error(`No history for ${ticker}`);
954
989
  }
955
990
 
991
+ async getPriceHistory(ticker: string, exchange = "", range: TimeRange, _context?: MarketDataRequestContext): Promise<PricePoint[]> {
992
+ const params = RANGE_PARAMS[range];
993
+ return this.loadPriceHistory(ticker, exchange, params.range, params.interval as ManualChartResolution);
994
+ }
995
+
996
+ async getPriceHistoryForResolution(
997
+ ticker: string,
998
+ exchange = "",
999
+ bufferRange: TimeRange,
1000
+ resolution: ManualChartResolution,
1001
+ _context?: MarketDataRequestContext,
1002
+ ): Promise<PricePoint[]> {
1003
+ const params = RANGE_PARAMS[bufferRange];
1004
+ return this.loadPriceHistory(ticker, exchange, params.range, resolution);
1005
+ }
1006
+
956
1007
  // ── Options Chain ──────────────────────────────────────────────────
957
1008
 
958
1009
  private mapContract(raw: Record<string, any>): OptionContract {
@@ -0,0 +1,99 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useRef, type Dispatch } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
5
+ import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
6
+ import { getCurrentThemeId } from "../theme/colors";
7
+
8
+ const TEST_PANE_ID = "ticker-detail:test";
9
+
10
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
11
+ let capturedDispatch: Dispatch<AppAction> | null = null;
12
+
13
+ function createTickerDetailConfig(symbol: string): AppConfig {
14
+ const config = createDefaultConfig("/tmp/gloomberb-test");
15
+ const layout = {
16
+ dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
17
+ instances: [{
18
+ instanceId: TEST_PANE_ID,
19
+ paneId: "ticker-detail",
20
+ binding: { kind: "fixed" as const, symbol },
21
+ }],
22
+ floating: [],
23
+ };
24
+
25
+ return {
26
+ ...config,
27
+ layout,
28
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
29
+ };
30
+ }
31
+
32
+ function DispatchCapture() {
33
+ capturedDispatch = useAppDispatch();
34
+ return null;
35
+ }
36
+
37
+ function PaneTickerHarness() {
38
+ const renderCountRef = useRef(0);
39
+ renderCountRef.current += 1;
40
+ const { symbol } = usePaneTicker();
41
+ return <text>{`${symbol ?? "none"}:${renderCountRef.current}`}</text>;
42
+ }
43
+
44
+ function ThemeSelectorHarness() {
45
+ const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
46
+ return <text>{`${focusedPaneId ?? "none"}:${getCurrentThemeId()}`}</text>;
47
+ }
48
+
49
+ describe("pane selectors", () => {
50
+ afterEach(() => {
51
+ testSetup?.renderer.destroy();
52
+ testSetup = undefined;
53
+ capturedDispatch = null;
54
+ });
55
+
56
+ test("does not rerender usePaneTicker consumers for unrelated app state updates", async () => {
57
+ testSetup = await testRender(
58
+ <AppProvider config={createTickerDetailConfig("AAPL")}>
59
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
60
+ <DispatchCapture />
61
+ <PaneTickerHarness />
62
+ </PaneInstanceProvider>
63
+ </AppProvider>,
64
+ { width: 24, height: 4 },
65
+ );
66
+
67
+ await testSetup.renderOnce();
68
+ expect(testSetup.captureCharFrame()).toContain("AAPL:1");
69
+
70
+ await act(() => {
71
+ capturedDispatch?.({ type: "SET_COMMAND_BAR", open: true, query: "ticker" });
72
+ });
73
+
74
+ await testSetup.renderOnce();
75
+ await testSetup.renderOnce();
76
+ expect(testSetup.captureCharFrame()).toContain("AAPL:1");
77
+ });
78
+
79
+ test("rerenders selector consumers when the theme changes", async () => {
80
+ testSetup = await testRender(
81
+ <AppProvider config={createTickerDetailConfig("AAPL")}>
82
+ <DispatchCapture />
83
+ <ThemeSelectorHarness />
84
+ </AppProvider>,
85
+ { width: 32, height: 4 },
86
+ );
87
+
88
+ await testSetup.renderOnce();
89
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:amber`);
90
+
91
+ await act(() => {
92
+ capturedDispatch?.({ type: "SET_THEME", theme: "green" });
93
+ });
94
+
95
+ await testSetup.renderOnce();
96
+ await testSetup.renderOnce();
97
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
98
+ });
99
+ });
@@ -214,6 +214,24 @@ describe("broker account cache", () => {
214
214
  });
215
215
  });
216
216
 
217
+ describe("pane state updates", () => {
218
+ test("returns the existing state object when a pane patch is a no-op", () => {
219
+ const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
220
+ initial.paneState["portfolio-list:main"] = {
221
+ collectionId: "main",
222
+ cursorSymbol: "AAPL",
223
+ };
224
+
225
+ const next = appReducer(initial, {
226
+ type: "UPDATE_PANE_STATE",
227
+ paneId: "portfolio-list:main",
228
+ patch: { cursorSymbol: "AAPL" },
229
+ });
230
+
231
+ expect(next).toBe(initial);
232
+ });
233
+ });
234
+
217
235
  describe("quote merging", () => {
218
236
  test("does not overwrite live broker quotes with cloud updates", () => {
219
237
  const config = createDefaultConfig("/tmp/gloomberb-test");