gloomberb 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -83,6 +83,19 @@ const CHART_RESOLUTION_POINTS_PER_DAY: Record<ManualChartResolution, number> = {
83
83
  "1mo": 1 / 21,
84
84
  };
85
85
 
86
+ const TIME_RANGE_APPROXIMATE_DAYS: Record<TimeRange, number> = {
87
+ "1D": 1,
88
+ "1W": 7,
89
+ "1M": 30,
90
+ "3M": 90,
91
+ "6M": 180,
92
+ "1Y": 365,
93
+ "5Y": 5 * 365,
94
+ "ALL": 50 * 365,
95
+ };
96
+
97
+ const MINIMUM_USEFUL_CHART_POINTS = 2;
98
+
86
99
  const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
87
100
 
88
101
  function getTimeRangeIndex(range: TimeRange): number {
@@ -183,6 +196,34 @@ export function getSupportMaxRange(
183
196
  return normalizeChartResolutionSupport(support).find((entry) => entry.resolution === resolution)?.maxRange ?? null;
184
197
  }
185
198
 
199
+ export function getSupportedChartResolutionsForViewport(
200
+ range: TimeRange,
201
+ support: readonly ChartResolutionSupport[],
202
+ dateWindow?: { start: string | Date; end: string | Date },
203
+ ): ManualChartResolution[] {
204
+ const start = dateWindow ? new Date(dateWindow.start) : null;
205
+ const end = dateWindow ? new Date(dateWindow.end) : null;
206
+ const hasValidDateWindow = !!start
207
+ && Number.isFinite(start.getTime())
208
+ && !!end
209
+ && Number.isFinite(end.getTime())
210
+ && start.getTime() <= end.getTime();
211
+ const viewportSpanMs = hasValidDateWindow
212
+ ? Math.max(end.getTime() - start.getTime(), 0)
213
+ : TIME_RANGE_APPROXIMATE_DAYS[range] * CHART_RESOLUTION_STEP_MS["1d"];
214
+ return normalizeChartResolutionSupport(support)
215
+ .filter((entry) => (
216
+ hasValidDateWindow
217
+ ? isDateWindowWithinTimeRange(start, end, entry.maxRange)
218
+ : isTimeRangeAtOrBelow(range, entry.maxRange)
219
+ ))
220
+ .filter((entry) => (
221
+ viewportSpanMs / CHART_RESOLUTION_STEP_MS[entry.resolution]
222
+ >= MINIMUM_USEFUL_CHART_POINTS
223
+ ))
224
+ .map((entry) => entry.resolution);
225
+ }
226
+
186
227
  function minTimeRange(left: TimeRange, right: TimeRange): TimeRange {
187
228
  return compareTimeRange(left, right) <= 0 ? left : right;
188
229
  }
@@ -105,12 +105,64 @@ describe("resolveChartSpecData", () => {
105
105
  });
106
106
 
107
107
  expect(genericHistoryCalls).toBe(0);
108
- expect(result.series).toEqual([]);
108
+ // The series keeps its legend slot with no observations so the chart and the
109
+ // series editor agree on what the user added.
110
+ expect(result.series.map((entry) => [entry.id, entry.points.length])).toEqual([["price", 0]]);
109
111
  expect(result.errors).toEqual([
110
112
  "price: Requested 5m price history is unavailable for TEST. Choose Auto or a supported interval.",
111
113
  ]);
112
114
  });
113
115
 
116
+ test("falls back from an unsupported manual interval while exposing source capabilities", async () => {
117
+ const requestedResolutions: string[] = [];
118
+ const provider = createTestDataProvider({
119
+ getTickerFinancials: async () => emptyFinancials(),
120
+ getChartResolutionSupport: () => [
121
+ { resolution: "15m", maxRange: "1M" },
122
+ { resolution: "1h", maxRange: "3M" },
123
+ ],
124
+ getPriceHistoryForResolution: async (_symbol, _exchange, _range, resolution) => {
125
+ requestedResolutions.push(resolution);
126
+ return [{ date: new Date("2026-07-30T15:00:00Z"), close: 100 }];
127
+ },
128
+ });
129
+ const spec: ChartSpec = {
130
+ version: CHART_SPEC_VERSION,
131
+ viewport: { range: "1M", resolution: "45m" },
132
+ panels: [{ id: "main" }],
133
+ series: [{
134
+ id: "price",
135
+ source: {
136
+ kind: "security",
137
+ instrument: { symbol: "TEST", exchange: "NASDAQ" },
138
+ fieldId: "market.close",
139
+ },
140
+ style: "line",
141
+ transform: "raw",
142
+ axis: "left",
143
+ panelId: "main",
144
+ interpolation: "none",
145
+ }],
146
+ studies: [],
147
+ };
148
+
149
+ const result = await resolveChartSpecData(spec, {
150
+ dataProvider: provider,
151
+ now: new Date("2026-07-30T16:00:00Z"),
152
+ loadFredSeries: async () => fredLoad(),
153
+ });
154
+
155
+ expect(requestedResolutions).toEqual(["15m"]);
156
+ expect(result.errors).toEqual([]);
157
+ expect(result.warnings).toContain(
158
+ "45M data is unavailable for this range. Auto resolution was used instead.",
159
+ );
160
+ expect(result.resolutionSupport).toEqual([
161
+ { resolution: "15m", maxRange: "1M" },
162
+ { resolution: "1h", maxRange: "3M" },
163
+ ]);
164
+ });
165
+
114
166
  test("keeps the snapshot exchange when a newer quote omits it", async () => {
115
167
  const historyExchanges: string[] = [];
116
168
  const source = {
@@ -179,6 +231,11 @@ describe("resolveChartSpecData", () => {
179
231
  expect(historyExchanges).toEqual(["XNAS"]);
180
232
  expect(result.series[0]?.points.at(-1)?.date.toISOString())
181
233
  .toBe("2026-07-30T15:26:00.000Z");
234
+ expect(result.series[0]?.timeBasis).toMatchObject({
235
+ kind: "market",
236
+ timeZone: "America/New_York",
237
+ cadenceMs: 5 * 60_000,
238
+ });
182
239
  });
183
240
 
184
241
  test("resolves the exchange before selecting an adaptive Auto resolution", async () => {
@@ -401,6 +458,87 @@ describe("resolveChartSpecData", () => {
401
458
  });
402
459
  });
403
460
 
461
+ test("accumulates historical windows without replacing the current tail", async () => {
462
+ const detailedRequests: Array<{ start: string; end: string }> = [];
463
+ const currentHistory = [
464
+ { date: "2026-01-02T16:00:00.000Z" as unknown as Date, close: 110 },
465
+ { date: new Date("2026-07-30T16:00:00.000Z"), close: 120 },
466
+ ];
467
+ const historicalHistory = [
468
+ { date: new Date("2024-08-01T16:00:00.000Z"), close: 90 },
469
+ { date: new Date("2025-07-29T16:00:00.000Z"), close: 100 },
470
+ ];
471
+ const provider = createTestDataProvider({
472
+ getTickerFinancials: async () => emptyFinancials(),
473
+ getChartResolutionSupport: () => [
474
+ { resolution: "1h", maxRange: "1Y" },
475
+ { resolution: "1d", maxRange: "ALL" },
476
+ ],
477
+ getPriceHistoryForResolution: async () => currentHistory,
478
+ getDetailedPriceHistory: async (_symbol, _exchange, start, end) => {
479
+ detailedRequests.push({
480
+ start: start.toISOString(),
481
+ end: end.toISOString(),
482
+ });
483
+ return historicalHistory;
484
+ },
485
+ });
486
+ const spec: ChartSpec = {
487
+ version: CHART_SPEC_VERSION,
488
+ viewport: { range: "1Y", resolution: "1h" },
489
+ panels: [{ id: "main" }],
490
+ series: [{
491
+ id: "price",
492
+ source: {
493
+ kind: "security",
494
+ instrument: { symbol: "TEST", exchange: "NASDAQ" },
495
+ fieldId: "market.close",
496
+ },
497
+ style: "line",
498
+ transform: "raw",
499
+ axis: "left",
500
+ panelId: "main",
501
+ interpolation: "none",
502
+ }],
503
+ studies: [],
504
+ };
505
+ const sources = {
506
+ dataProvider: provider,
507
+ now: new Date("2026-07-30T16:00:00.000Z"),
508
+ loadFredSeries: async () => fredLoad(),
509
+ };
510
+ const cache = new ChartResolveCache();
511
+
512
+ const current = await resolveChartSpecData(spec, sources, cache);
513
+ const historical = await resolveChartSpecData(spec, sources, cache, {
514
+ requestViewport: {
515
+ start: new Date("2024-07-30T16:00:00.000Z"),
516
+ end: new Date("2025-07-30T16:00:00.000Z"),
517
+ },
518
+ });
519
+ const revisitedCurrent = await resolveChartSpecData(spec, sources, cache);
520
+ const bufferedDates = (result: typeof current) => (
521
+ result.bufferedSeries?.[0]?.points.map((point) => point.date.toISOString())
522
+ );
523
+
524
+ expect(detailedRequests).toEqual([{
525
+ start: "2024-07-30T16:00:00.000Z",
526
+ end: "2025-07-30T16:00:00.001Z",
527
+ }]);
528
+ expect(current.resolutionSupport).toEqual([
529
+ { resolution: "1h", maxRange: "1Y" },
530
+ { resolution: "1d", maxRange: "ALL" },
531
+ ]);
532
+ expect(bufferedDates(historical)).toEqual([
533
+ "2024-08-01T16:00:00.000Z",
534
+ "2025-07-29T16:00:00.000Z",
535
+ "2026-01-02T16:00:00.000Z",
536
+ "2026-07-30T16:00:00.000Z",
537
+ ]);
538
+ expect(bufferedDates(revisitedCurrent)).toEqual(bufferedDates(historical));
539
+ expect(revisitedCurrent.bufferedSeries?.[0]?.points.at(-1)?.value).toBe(120);
540
+ });
541
+
404
542
  test("keeps percent transforms and studies defined in a panned history window", async () => {
405
543
  const provider = createTestDataProvider({
406
544
  getTickerFinancials: async () => emptyFinancials(),
@@ -1029,6 +1167,74 @@ describe("resolveChartSpecData", () => {
1029
1167
  expect(sma.points.at(-1)?.value).toBeCloseTo(15.5);
1030
1168
  });
1031
1169
 
1170
+ test("keeps a quote received after resolution starts inside the latest viewport", async () => {
1171
+ const now = Date.parse("2026-08-05T14:00:00Z");
1172
+ const quoteTime = now + 60_000;
1173
+ const provider = createTestDataProvider({
1174
+ getTickerFinancials: async () => ({
1175
+ ...emptyFinancials(),
1176
+ quote: {
1177
+ symbol: "FRESH",
1178
+ price: 130,
1179
+ currency: "USD",
1180
+ change: 30,
1181
+ changePercent: 30,
1182
+ lastUpdated: quoteTime,
1183
+ listingExchangeName: "XNAS",
1184
+ marketState: "POST",
1185
+ postMarketPrice: 129,
1186
+ postMarketChange: -1,
1187
+ postMarketChangePercent: -0.77,
1188
+ },
1189
+ }),
1190
+ getPriceHistoryForResolution: async () => [
1191
+ { date: new Date(now - 2 * 86_400_000), open: 99, high: 102, low: 98, close: 100 },
1192
+ { date: new Date(now - 86_400_000), open: 100, high: 101, low: 99, close: 100 },
1193
+ ],
1194
+ });
1195
+ const spec: ChartSpec = {
1196
+ version: CHART_SPEC_VERSION,
1197
+ viewport: { range: "6M", resolution: "1d" },
1198
+ panels: [{ id: "main" }],
1199
+ series: [{
1200
+ id: "price",
1201
+ source: {
1202
+ kind: "security",
1203
+ instrument: { symbol: "FRESH", exchange: "XNAS" },
1204
+ fieldId: "market.ohlcv",
1205
+ },
1206
+ style: "candles",
1207
+ transform: "raw",
1208
+ axis: "left",
1209
+ panelId: "main",
1210
+ interpolation: "none",
1211
+ }],
1212
+ studies: [{
1213
+ id: "sma",
1214
+ kind: "sma",
1215
+ inputSeriesIds: ["price"],
1216
+ parameters: { period: 2 },
1217
+ panelId: "main",
1218
+ axis: "left",
1219
+ }],
1220
+ };
1221
+
1222
+ const result = await resolveChartSpecData(spec, {
1223
+ dataProvider: provider,
1224
+ now: new Date(now),
1225
+ loadFredSeries: async () => fredLoad(),
1226
+ });
1227
+
1228
+ expect(result.errors).toEqual([]);
1229
+ expect(result.viewport?.end.getTime()).toBe(quoteTime);
1230
+ const price = result.series.find((entry) => entry.id === "price");
1231
+ const sma = result.series.find((entry) => entry.id === "sma");
1232
+ expect(price?.points.at(-1)?.date.getTime()).toBe(quoteTime);
1233
+ expect(price?.points.at(-1)?.close).toBe(129);
1234
+ expect(price?.latestChangePercent).toBe(30);
1235
+ expect(sma?.points.at(-1)?.date.getTime()).toBe(quoteTime);
1236
+ });
1237
+
1032
1238
  test("reuses raw source loads while live quotes recompute the chart tail", async () => {
1033
1239
  const now = Date.parse("2026-05-15T20:30:00Z");
1034
1240
  const source = {
@@ -1,6 +1,7 @@
1
1
  import { appendLiveQuotePoint } from "./chart-data";
2
2
  import {
3
3
  getTimeRangeForDateWindow,
4
+ isDateWindowWithinTimeRange,
4
5
  subtractTimeRange,
5
6
  } from "./date-window";
6
7
  import {
@@ -45,6 +46,7 @@ import {
45
46
  publicTickerKey,
46
47
  resolveExchangeTimeZone,
47
48
  } from "../utils/exchanges";
49
+ import { getPricePointTimestamp } from "../utils/price-history";
48
50
  import type {
49
51
  ChartResolutionResult,
50
52
  ChartSeriesSpec,
@@ -87,6 +89,7 @@ export interface ChartResolveOptions {
87
89
  export class ChartResolveCache {
88
90
  readonly financialsByInstrument = new Map<string, Promise<TickerFinancials | null>>();
89
91
  readonly priceHistoryByRequest = new Map<string, Promise<TickerFinancials["priceHistory"]>>();
92
+ readonly accumulatedPriceHistory = new Map<string, TickerFinancials["priceHistory"]>();
90
93
  readonly resolutionSupportByInstrument = new Map<string, Promise<ChartResolutionSupport[]>>();
91
94
  readonly fredSeriesByRequest = new Map<string, Promise<FredSeriesLoadResult>>();
92
95
  }
@@ -99,6 +102,7 @@ interface DateBounds {
99
102
 
100
103
  interface PriceHistoryRequest {
101
104
  bounds: DateBounds;
105
+ visibleBounds: DateBounds;
102
106
  explicitWindow: boolean;
103
107
  fallbackRange: TimeRange;
104
108
  resolution: ManualChartResolution;
@@ -124,6 +128,32 @@ function instrumentLabel(spec: Extract<ChartSeriesSpec["source"], { kind: "secur
124
128
  return publicTickerKey(spec.instrument.symbol, spec.instrument.exchange);
125
129
  }
126
130
 
131
+ // A series that fails to load keeps its place with no observations. Dropping it
132
+ // made a series the user had added disappear from the legend while it still sat
133
+ // in the series editor, with nothing on screen to explain the difference.
134
+ function unloadableSeries(spec: ChartSeriesSpec, index: number, warning: string): ResolvedSeries {
135
+ const field = spec.source.kind === "security" ? getTimeSeriesField(spec.source.fieldId) : undefined;
136
+ const label = spec.source.kind === "economic"
137
+ ? `FRED ${spec.source.seriesId}`
138
+ : `${instrumentLabel(spec.source)} ${field?.shortLabel ?? spec.source.fieldId.split(".").at(-1) ?? "Series"}`;
139
+ return {
140
+ id: spec.id,
141
+ label: spec.label?.trim() || label,
142
+ color: spec.color ?? SERIES_COLORS[index % SERIES_COLORS.length]!,
143
+ unit: field?.unit ?? "",
144
+ unitGroup: field?.unitGroup ?? "unknown",
145
+ nativeFrequency: field?.nativeFrequency ?? "daily",
146
+ dataShape: field?.dataShape ?? "scalar",
147
+ style: spec.style,
148
+ transform: spec.transform,
149
+ axis: spec.axis === "right" ? "right" : "left",
150
+ panelId: spec.panelId,
151
+ interpolation: spec.interpolation,
152
+ warning,
153
+ points: [],
154
+ };
155
+ }
156
+
127
157
  function withQuoteExchange(
128
158
  source: Extract<ChartSeriesSpec["source"], { kind: "security" }>,
129
159
  ...quotes: Array<Quote | undefined>
@@ -221,7 +251,21 @@ function requestResolution(
221
251
  options: ChartResolveOptions,
222
252
  sharedSupport: readonly ChartResolutionSupport[],
223
253
  ): ManualChartResolution {
224
- if (spec.viewport.resolution !== "auto") return spec.viewport.resolution;
254
+ if (spec.viewport.resolution !== "auto") {
255
+ const maxRange = getSupportMaxRange(sharedSupport, spec.viewport.resolution);
256
+ const supported = sharedSupport.length === 0
257
+ || (
258
+ maxRange !== null
259
+ && bounds.start !== null
260
+ && bounds.end !== null
261
+ && isDateWindowWithinTimeRange(
262
+ new Date(bounds.start),
263
+ new Date(bounds.end),
264
+ maxRange,
265
+ )
266
+ );
267
+ if (supported) return spec.viewport.resolution;
268
+ }
225
269
  const runtimeBounds = runtimeAutoBounds(options);
226
270
  const adaptive = runtimeBounds && runtimeBounds.start !== null && runtimeBounds.end !== null
227
271
  ? getBestSupportedResolutionForVisibleWindow(
@@ -280,10 +324,73 @@ function filterPoints(points: readonly TimeSeriesPoint[], bounds: DateBounds): T
280
324
  });
281
325
  }
282
326
 
327
+ function followLatestMarketObservation(
328
+ bounds: DateBounds,
329
+ series: readonly ResolvedSeries[],
330
+ ): DateBounds {
331
+ if (bounds.end === null) return bounds;
332
+ let latest = bounds.end;
333
+ for (const entry of series) {
334
+ if (entry.timeBasis?.kind !== "market") continue;
335
+ for (const point of entry.points) {
336
+ const timestamp = point.date.getTime();
337
+ if (Number.isFinite(timestamp) && timestamp > latest) latest = timestamp;
338
+ }
339
+ }
340
+ if (latest === bounds.end) return bounds;
341
+ const shift = latest - bounds.end;
342
+ return {
343
+ start: bounds.start === null ? null : bounds.start + shift,
344
+ end: latest,
345
+ };
346
+ }
347
+
283
348
  function emptyFinancials(priceHistory: TickerFinancials["priceHistory"] = []): TickerFinancials {
284
349
  return { annualStatements: [], quarterlyStatements: [], priceHistory };
285
350
  }
286
351
 
352
+ function mergePriceHistoryWindows(
353
+ current: TickerFinancials["priceHistory"],
354
+ incoming: TickerFinancials["priceHistory"],
355
+ ): TickerFinancials["priceHistory"] {
356
+ const byTimestamp = new Map<number, TickerFinancials["priceHistory"][number]>();
357
+ for (const point of [...current, ...incoming]) {
358
+ const timestamp = getPricePointTimestamp(point);
359
+ if (Number.isFinite(timestamp)) {
360
+ byTimestamp.set(
361
+ timestamp,
362
+ point.date instanceof Date ? point : { ...point, date: new Date(timestamp) },
363
+ );
364
+ }
365
+ }
366
+ return [...byTimestamp.values()].sort(
367
+ (left, right) => getPricePointTimestamp(left) - getPricePointTimestamp(right),
368
+ );
369
+ }
370
+
371
+ function historyIntersectsBounds(
372
+ history: TickerFinancials["priceHistory"],
373
+ bounds: DateBounds,
374
+ ): boolean {
375
+ if (history.length === 0) return false;
376
+ if (bounds.start === null || bounds.end === null) return true;
377
+ return history.some((point) => {
378
+ const timestamp = getPricePointTimestamp(point);
379
+ return Number.isFinite(timestamp) && timestamp >= bounds.start! && timestamp <= bounds.end!;
380
+ });
381
+ }
382
+
383
+ function clampHistoryBoundsToSupport(
384
+ bounds: DateBounds,
385
+ maxRange: TimeRange | null,
386
+ ): DateBounds {
387
+ if (bounds.start === null || bounds.end === null || !maxRange || maxRange === "ALL") {
388
+ return bounds;
389
+ }
390
+ const supportedStart = subtractTimeRange(new Date(bounds.end), maxRange).getTime();
391
+ return { start: Math.max(bounds.start, supportedStart), end: bounds.end };
392
+ }
393
+
287
394
  function latestQuote(snapshot: Quote | undefined, override: Quote | undefined): Quote | undefined {
288
395
  if (!snapshot) return override;
289
396
  if (!override) return snapshot;
@@ -326,7 +433,7 @@ async function loadPriceHistory(
326
433
  request.resolution,
327
434
  context,
328
435
  );
329
- if (detailed.length > 0) return detailed;
436
+ if (historyIntersectsBounds(detailed, request.visibleBounds)) return detailed;
330
437
  } catch {
331
438
  // Fall through to trailing history when a provider cannot serve the exact window.
332
439
  }
@@ -340,7 +447,12 @@ async function loadPriceHistory(
340
447
  request.resolution,
341
448
  context,
342
449
  );
343
- if (resolved.length > 0) return resolved;
450
+ if (
451
+ resolved.length > 0
452
+ && (!request.explicitWindow || historyIntersectsBounds(resolved, request.visibleBounds))
453
+ ) {
454
+ return resolved;
455
+ }
344
456
  } catch {
345
457
  // Some providers expose the resolution API but only support a subset.
346
458
  }
@@ -352,12 +464,20 @@ async function loadPriceHistory(
352
464
  `Requested ${request.resolution} price history is unavailable for ${instrumentLabel(source)}. Choose Auto or a supported interval.`,
353
465
  );
354
466
  }
355
- return provider.getPriceHistory(
467
+ const fallback = await provider.getPriceHistory(
356
468
  source.instrument.symbol,
357
469
  source.instrument.exchange ?? "",
358
470
  request.fallbackRange,
359
471
  context,
360
472
  );
473
+ if (
474
+ request.explicitWindow
475
+ && fallback.length > 0
476
+ && !historyIntersectsBounds(fallback, request.visibleBounds)
477
+ ) {
478
+ throw new Error(`Price history for the requested window is unavailable for ${instrumentLabel(source)}.`);
479
+ }
480
+ return fallback;
361
481
  }
362
482
 
363
483
  function baseSecuritySeries(
@@ -381,10 +501,14 @@ function baseSecuritySeries(
381
501
  const marketExchange = spec.source.instrument.exchange
382
502
  || financials.quote?.listingExchangeName
383
503
  || financials.quote?.exchangeName;
384
- const marketTimeZone = isMarketFieldId(spec.source.fieldId)
504
+ const marketField = isMarketFieldId(spec.source.fieldId);
505
+ const marketTimeZone = marketField
385
506
  && canonicalExchange(marketExchange) !== "CCC"
386
507
  ? resolveExchangeTimeZone(marketExchange)
387
508
  : null;
509
+ const latestChangePercent = marketField && field.unit.startsWith("currency")
510
+ ? financials.quote?.changePercent
511
+ : undefined;
388
512
  return {
389
513
  id: spec.id,
390
514
  label: spec.label?.trim() || `${symbol} ${field.shortLabel}`,
@@ -410,6 +534,9 @@ function baseSecuritySeries(
410
534
  : undefined,
411
535
  }
412
536
  : undefined,
537
+ latestChangePercent: typeof latestChangePercent === "number" && Number.isFinite(latestChangePercent)
538
+ ? latestChangePercent
539
+ : undefined,
413
540
  points,
414
541
  };
415
542
  }
@@ -640,14 +767,12 @@ export async function resolveChartSpecData(
640
767
  ? [[instrumentKey(entry.source), entry.source] as const]
641
768
  : []
642
769
  ))).values()];
643
- const resolutionSupportSources = adaptiveBounds
644
- ? await Promise.all(activeMarketSources.map(async (source) => (
645
- source.instrument.exchange?.trim()
646
- ? source
647
- : sourceWithResolvedExchange(source, await loadFinancials(source))
648
- )))
649
- : activeMarketSources;
650
- const sharedSupport = adaptiveBounds && activeMarketSources.length > 0
770
+ const resolutionSupportSources = await Promise.all(activeMarketSources.map(async (source) => (
771
+ source.instrument.exchange?.trim()
772
+ ? source
773
+ : sourceWithResolvedExchange(source, await loadFinancials(source))
774
+ )));
775
+ const sharedSupport = activeMarketSources.length > 0
651
776
  ? intersectChartResolutionSupport(await Promise.all(
652
777
  resolutionSupportSources.map((source) => loadResolutionSupport(source)),
653
778
  ))
@@ -659,6 +784,14 @@ export async function resolveChartSpecData(
659
784
  options,
660
785
  sharedSupport,
661
786
  );
787
+ if (
788
+ spec.viewport.resolution !== "auto"
789
+ && initialResolution !== spec.viewport.resolution
790
+ ) {
791
+ warnings.push(
792
+ `${spec.viewport.resolution.toUpperCase()} data is unavailable for this range. Auto resolution was used instead.`,
793
+ );
794
+ }
662
795
  const requestVisibleBounds = requestBounds ?? initialVisibleBounds;
663
796
  const initialCalculationBounds = calculationBounds(
664
797
  spec,
@@ -672,16 +805,18 @@ export async function resolveChartSpecData(
672
805
  source: Extract<ChartSeriesSpec["source"], { kind: "security" }>,
673
806
  all = false,
674
807
  ) => {
675
- const requestedFallbackRange = all
676
- ? "ALL"
677
- : trailingRangeForStart(initialCalculationBounds.start, referenceNow);
678
808
  const support = await loadResolutionSupport(source);
679
809
  const maxRange = getSupportMaxRange(support, initialResolution);
810
+ const historyBounds = clampHistoryBoundsToSupport(initialCalculationBounds, maxRange);
811
+ const requestedFallbackRange = all
812
+ ? "ALL"
813
+ : trailingRangeForStart(historyBounds.start, referenceNow);
680
814
  const fallbackRange = maxRange
681
815
  ? clampTimeRangeToMaxRange(requestedFallbackRange, maxRange)
682
816
  : requestedFallbackRange;
683
817
  const request: PriceHistoryRequest = {
684
- bounds: initialCalculationBounds,
818
+ bounds: historyBounds,
819
+ visibleBounds: requestVisibleBounds,
685
820
  explicitWindow: hasExplicitWindow,
686
821
  fallbackRange,
687
822
  resolution: initialResolution,
@@ -700,7 +835,24 @@ export async function resolveChartSpecData(
700
835
  pending = loadPriceHistory(sources.dataProvider!, source, request);
701
836
  cache.priceHistoryByRequest.set(key, pending);
702
837
  }
703
- return pending;
838
+ const history = await pending;
839
+ const accumulationKey = `${instrumentKey(source)}|${request.resolution}`;
840
+ const previousHistory = cache.accumulatedPriceHistory.get(accumulationKey) ?? [];
841
+ if (
842
+ request.explicitWindow
843
+ && !historyIntersectsBounds(history, request.visibleBounds)
844
+ && !historyIntersectsBounds(previousHistory, request.visibleBounds)
845
+ ) {
846
+ throw new Error(
847
+ `Price history for the requested window is unavailable for ${instrumentLabel(source)}.`,
848
+ );
849
+ }
850
+ const accumulated = mergePriceHistoryWindows(
851
+ previousHistory,
852
+ history,
853
+ );
854
+ cache.accumulatedPriceHistory.set(accumulationKey, accumulated);
855
+ return accumulated;
704
856
  };
705
857
 
706
858
  const loadEconomicSeries = (request: FredSeriesRequest) => {
@@ -739,17 +891,19 @@ export async function resolveChartSpecData(
739
891
  ? sources.quoteOverrides?.get(chartQuoteOverrideKeyForSource(source))
740
892
  : undefined;
741
893
  const financialsPromise = loadFinancials(source);
894
+ let resolvedSource = source;
742
895
  let financials: TickerFinancials | null;
743
896
  let history: TickerFinancials["priceHistory"] | null;
744
897
  if (needsHistory && !source.instrument.exchange?.trim()) {
745
898
  financials = await financialsPromise;
746
- const historySource = sourceWithResolvedExchange(source, financials);
747
- history = await loadHistory(historySource, quoteDerivedValuation);
899
+ resolvedSource = sourceWithResolvedExchange(source, financials);
900
+ history = await loadHistory(resolvedSource, quoteDerivedValuation);
748
901
  } else {
749
902
  [financials, history] = await Promise.all([
750
903
  financialsPromise,
751
904
  needsHistory ? loadHistory(source, quoteDerivedValuation) : Promise.resolve(null),
752
905
  ]);
906
+ resolvedSource = sourceWithResolvedExchange(source, financials);
753
907
  }
754
908
  const liveBarResolution = isOhlcSeriesStyle(seriesSpec.style) ? initialResolution : undefined;
755
909
  const merged = history
@@ -758,15 +912,21 @@ export async function resolveChartSpecData(
758
912
  ? { ...financials, quote: latestQuote(financials.quote, quoteOverride) }
759
913
  : financials;
760
914
  if (!merged) throw new Error(`No financial data is available for ${instrumentLabel(source)}.`);
761
- const result = baseSecuritySeries(seriesSpec, merged, index, initialResolution);
915
+ const result = baseSecuritySeries(
916
+ resolvedSource === source ? seriesSpec : { ...seriesSpec, source: resolvedSource },
917
+ merged,
918
+ index,
919
+ initialResolution,
920
+ );
762
921
  if (!result) throw new Error(`Unknown field ${source.fieldId}.`);
763
922
  if (isFundamentalFieldId(source.fieldId) && fundamentalSeriesUsesAvailabilityFallback(merged, source)) {
764
923
  result.warning = "Publication dates are unavailable for some observations; period-end dates are used as a fallback.";
765
924
  }
766
925
  return result;
767
926
  } catch (error) {
768
- errors.push(`${seriesSpec.label ?? seriesSpec.id}: ${error instanceof Error ? error.message : String(error)}`);
769
- return null;
927
+ const message = error instanceof Error ? error.message : String(error);
928
+ errors.push(`${seriesSpec.label ?? seriesSpec.id}: ${message}`);
929
+ return unloadableSeries(seriesSpec, index, message);
770
930
  }
771
931
  }));
772
932
 
@@ -774,13 +934,19 @@ export async function resolveChartSpecData(
774
934
  const marketTimelineSeries = primaryMarketSeries?.visible === false
775
935
  ? rawSeries.filter((entry) => entry.id === primaryMarketSeries.id)
776
936
  : [];
777
- // Preset ranges describe a window ending at the requested reference time,
778
- // even when the newest filing or economic observation lags that endpoint.
779
- // Re-anchoring to the latest observation both mislabels the range and asks
780
- // providers for a different window than the one ultimately rendered.
781
- const bounds = initialVisibleBounds;
937
+ // Preset ranges normally end at the requested reference time. A quote fetched
938
+ // asynchronously can be timestamped just after that reference, so advance an
939
+ // untouched market viewport by the same amount instead of clipping its tail.
940
+ // Explicit and user-created windows stay fixed through hasExplicitWindow.
941
+ const bounds = hasExplicitWindow
942
+ ? initialVisibleBounds
943
+ : followLatestMarketObservation(initialVisibleBounds, rawSeries);
782
944
  const resolution = initialResolution;
783
- const studyBounds = initialCalculationBounds;
945
+ const studyBounds = bounds.end !== null
946
+ && initialCalculationBounds.end !== null
947
+ && bounds.end > initialCalculationBounds.end
948
+ ? { ...initialCalculationBounds, end: bounds.end }
949
+ : initialCalculationBounds;
784
950
  const baseSeries = rawSeries
785
951
  .filter((entry) => visibleSeriesIds.has(entry.id))
786
952
  .map((entry) => prepareBaseSeriesForStudies(entry, bounds, false, requestVisibleBounds));
@@ -850,6 +1016,7 @@ export async function resolveChartSpecData(
850
1016
  : undefined;
851
1017
  return {
852
1018
  series: resolved,
1019
+ ...(sharedSupport.length > 0 ? { resolutionSupport: sharedSupport } : {}),
853
1020
  legendSeries,
854
1021
  ...(spec.viewport.maxPoints === undefined ? { bufferedSeries } : {}),
855
1022
  ...(marketTimelineSeries.length > 0 ? { timelineSeries: marketTimelineSeries } : {}),