gloomberb 0.7.3 → 0.7.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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/chart-renderer.ts +109 -32
  3. package/src/components/chart/chart-types.ts +18 -0
  4. package/src/components/chart/chart.test.ts +152 -5
  5. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  6. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  7. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  8. package/src/components/chart/market-session.ts +187 -0
  9. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  10. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  11. package/src/components/chart/stock-chart.tsx +36 -8
  12. package/src/components/chart/time-axis-label.tsx +56 -2
  13. package/src/components/command-bar/command-bar.test.tsx +1 -1
  14. package/src/components/command-bar/command-bar.tsx +7 -1
  15. package/src/components/command-bar/list-model.ts +8 -4
  16. package/src/components/command-bar/view-model.test.ts +20 -0
  17. package/src/components/command-bar/view-model.ts +15 -3
  18. package/src/components/ticker-list-table.tsx +4 -15
  19. package/src/core/state/session-persistence.ts +25 -0
  20. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  21. package/src/market-data/coordinator.test.ts +122 -2
  22. package/src/market-data/coordinator.ts +192 -55
  23. package/src/market-data/hooks.test.tsx +31 -0
  24. package/src/market-data/hooks.tsx +1 -0
  25. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  26. package/src/plugins/builtin/chat-controller.ts +8 -7
  27. package/src/plugins/builtin/chat.test.tsx +55 -0
  28. package/src/plugins/builtin/chat.tsx +20 -6
  29. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  30. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  31. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  32. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  33. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  34. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  35. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  36. package/src/plugins/builtin/sectors/index.tsx +30 -9
  37. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  38. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  39. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  40. package/src/sources/gloomberb-cloud.test.ts +55 -2
  41. package/src/sources/gloomberb-cloud.ts +180 -29
  42. package/src/sources/provider-router.test.ts +567 -10
  43. package/src/sources/provider-router.ts +323 -93
  44. package/src/sources/yahoo-finance.test.ts +37 -0
  45. package/src/sources/yahoo-finance.ts +48 -2
  46. package/src/state/app-bootstrap.test.ts +50 -23
  47. package/src/state/app-bootstrap.ts +26 -10
  48. package/src/state/session-persistence.test.ts +5 -3
  49. package/src/state/use-inline-tickers.ts +3 -0
  50. package/src/state/use-quote-streaming.ts +11 -0
  51. package/src/types/data-provider.ts +5 -1
  52. package/src/types/financials.ts +1 -0
  53. package/src/utils/api-client.test.ts +120 -0
  54. package/src/utils/api-client.ts +66 -19
  55. package/src/utils/exchanges.test.ts +16 -0
  56. package/src/utils/exchanges.ts +33 -0
  57. package/src/utils/options.ts +1 -1
  58. package/src/utils/price-history.test.ts +28 -1
  59. package/src/utils/price-history.ts +16 -0
  60. package/src/utils/quote-freshness.test.ts +150 -0
  61. package/src/utils/quote-freshness.ts +42 -1
  62. package/src/utils/quote-resolution.ts +2 -0
  63. package/src/utils/quote-time.test.ts +34 -0
  64. package/src/utils/quote-time.ts +18 -5
  65. package/src/version.ts +1 -1
  66. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -15,10 +15,12 @@ import {
15
15
  resolveAxisFractionDigits,
16
16
  type StyledContent,
17
17
  } from "./chart-renderer";
18
- import type { ChartAxisMode, ComparisonChartRenderMode } from "./chart-types";
18
+ import type { ChartAxisMode, ChartColors, ChartMarketSession, ChartSessionBackgroundSpan, ComparisonChartRenderMode } from "./chart-types";
19
+ import { resolveExtendedHoursBackgroundSpans } from "./market-session";
19
20
 
20
21
  const LAYER_FILL = 1;
21
22
  const LAYER_DATA = 2;
23
+ type ComparisonChartColors = Pick<ChartColors, "bgColor" | "gridColor" | "crosshairColor" | "preMarketBgColor" | "postMarketBgColor">;
22
24
 
23
25
  export interface ComparisonChartScene {
24
26
  dates: Date[];
@@ -29,11 +31,8 @@ export interface ComparisonChartScene {
29
31
  mode: ComparisonChartRenderMode;
30
32
  axisMode: ChartAxisMode;
31
33
  selectedSymbol: string | null;
32
- colors: {
33
- bgColor: string;
34
- gridColor: string;
35
- crosshairColor: string;
36
- };
34
+ colors: ComparisonChartColors;
35
+ sessionBackgroundSpans: ChartSessionBackgroundSpan[];
37
36
  min: number;
38
37
  max: number;
39
38
  activeIdx: number;
@@ -55,11 +54,8 @@ export interface RenderComparisonChartOptions {
55
54
  cursorX: number | null;
56
55
  cursorY: number | null;
57
56
  selectedSymbol: string | null;
58
- colors: {
59
- bgColor: string;
60
- gridColor: string;
61
- crosshairColor: string;
62
- };
57
+ colors: ComparisonChartColors;
58
+ marketSession?: ChartMarketSession | null;
63
59
  }
64
60
 
65
61
  export interface RenderComparisonChartResult {
@@ -90,6 +86,16 @@ function getComparisonDotX(index: number, count: number, width: number): number
90
86
  return Math.round((index / (count - 1)) * Math.max(width - 1, 0));
91
87
  }
92
88
 
89
+ function getComparisonPointBand(index: number, pointCount: number, width: number): { left: number; right: number } {
90
+ const currentX = getComparisonDotX(index, pointCount, width);
91
+ const previousX = index > 0 ? getComparisonDotX(index - 1, pointCount, width) : currentX;
92
+ const nextX = index < pointCount - 1 ? getComparisonDotX(index + 1, pointCount, width) : currentX;
93
+ return {
94
+ left: index === 0 ? 0 : Math.floor((previousX + currentX) / 2) + 1,
95
+ right: index === pointCount - 1 ? Math.max(width - 1, 0) : Math.floor((currentX + nextX) / 2),
96
+ };
97
+ }
98
+
93
99
  function getScaledY(value: number, min: number, max: number, chartTop: number, chartBottom: number): number {
94
100
  const range = max - min || 1;
95
101
  const chartHeight = chartBottom - chartTop;
@@ -223,6 +229,29 @@ function drawComparisonAreaSeries(
223
229
  }
224
230
  }
225
231
 
232
+ function drawSessionBackgrounds(
233
+ buf: ReturnType<typeof createPixelBuffer>,
234
+ spans: readonly ChartSessionBackgroundSpan[],
235
+ pointCount: number,
236
+ yTop: number,
237
+ yBottom: number,
238
+ colors: ComparisonChartColors,
239
+ ) {
240
+ if (spans.length === 0 || pointCount === 0) return;
241
+ for (const span of spans) {
242
+ const startIndex = Math.max(Math.min(span.startIndex, pointCount - 1), 0);
243
+ const endIndex = Math.max(Math.min(span.endIndex, pointCount - 1), startIndex);
244
+ const startBand = getComparisonPointBand(startIndex, pointCount, buf.width);
245
+ const endBand = getComparisonPointBand(endIndex, pointCount, buf.width);
246
+ const color = span.kind === "pre" ? colors.preMarketBgColor : colors.postMarketBgColor;
247
+ for (let y = Math.max(yTop, 0); y <= Math.min(yBottom, Math.max(buf.height - 1, 0)); y += 1) {
248
+ for (let x = Math.max(startBand.left, 0); x <= Math.min(endBand.right, Math.max(buf.width - 1, 0)); x += 1) {
249
+ buf.backgrounds[y]![x] = color;
250
+ }
251
+ }
252
+ }
253
+ }
254
+
226
255
  function getActiveIndex(pointCount: number, width: number, cursorX: number | null): number {
227
256
  if (pointCount <= 0) return 0;
228
257
  if (cursorX === null || cursorX < 0 || cursorX >= width) {
@@ -294,6 +323,7 @@ export function buildComparisonChartScene(
294
323
  const crosshairValue = cursorY === null
295
324
  ? null
296
325
  : max - (cursorY / Math.max(chartRows - 1, 1)) * range;
326
+ const sessionBackgroundSpans = resolveExtendedHoursBackgroundSpans(projection.dates, opts.marketSession);
297
327
 
298
328
  return {
299
329
  dates: projection.dates,
@@ -305,6 +335,7 @@ export function buildComparisonChartScene(
305
335
  axisMode: projection.effectiveAxisMode,
306
336
  selectedSymbol: selectedSeries?.symbol ?? null,
307
337
  colors: opts.colors,
338
+ sessionBackgroundSpans,
308
339
  min,
309
340
  max,
310
341
  activeIdx,
@@ -354,6 +385,14 @@ export function renderComparisonChart(
354
385
  getComparisonAxisFractionDigitFloor(priceRange),
355
386
  )
356
387
  : null;
388
+ drawSessionBackgrounds(
389
+ buf,
390
+ scene.sessionBackgroundSpans,
391
+ scene.dates.length,
392
+ 0,
393
+ chartDotBottom,
394
+ scene.colors,
395
+ );
357
396
  drawGridLines(buf, gridLines.map((line) => line.y), scene.colors.gridColor);
358
397
 
359
398
  const selectedSeries = getSelectedSeries(scene.series, scene.selectedSymbol);
@@ -98,6 +98,7 @@ import {
98
98
  import { resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
99
99
  import { TimeAxisLabel } from "./time-axis-label";
100
100
  import { PriceAxisLabels } from "./price-axis-labels";
101
+ import { getChartMarketSessionKey, resolveChartMarketSession } from "./market-session";
101
102
 
102
103
  const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
103
104
  area: "A",
@@ -392,6 +393,7 @@ function buildComparisonNativeBitmapKey(
392
393
  pixelWidth: number,
393
394
  pixelHeight: number,
394
395
  paletteKey: string,
396
+ marketSessionKey: string,
395
397
  ): string {
396
398
  const fingerprint = projection.series
397
399
  .map((series) => [
@@ -412,6 +414,7 @@ function buildComparisonNativeBitmapKey(
412
414
  pixelWidth,
413
415
  pixelHeight,
414
416
  paletteKey,
417
+ marketSessionKey,
415
418
  fingerprint,
416
419
  ].join("::");
417
420
  }
@@ -598,7 +601,16 @@ function ComparisonStockChartView({
598
601
  bgColor: colors.bg,
599
602
  gridColor: blendHex(colors.bg, colors.border, 0.55),
600
603
  crosshairColor: colors.borderFocused,
604
+ preMarketBgColor: blendHex(colors.bg, "#1d4ed8", 0.18),
605
+ postMarketBgColor: blendHex(colors.bg, "#b45309", 0.18),
601
606
  }), [colors.bg, colors.border, colors.borderFocused]);
607
+ const marketSession = useMemo(() => resolveChartMarketSession(symbolSources.map((source) => ({
608
+ exchange: source.exchange,
609
+ primaryExchange: source.instrument?.primaryExchange,
610
+ currency: source.currency,
611
+ assetCategory: source.instrument?.secType,
612
+ }))), [symbolSources]);
613
+ const marketSessionKey = useMemo(() => getChartMarketSessionKey(marketSession), [marketSession]);
602
614
 
603
615
  const setSelectedSymbol = (symbol: string) => {
604
616
  setViewState((current) => (
@@ -1088,7 +1100,8 @@ function ComparisonStockChartView({
1088
1100
  cursorY: null,
1089
1101
  selectedSymbol: viewState.selectedSymbol,
1090
1102
  colors: chartColors,
1091
- }), [chartColors, chartHeight, chartWidth, projection, viewState.selectedSymbol]);
1103
+ marketSession,
1104
+ }), [chartColors, chartHeight, chartWidth, marketSession, projection, viewState.selectedSymbol]);
1092
1105
  const displayScene = useMemo(() => buildComparisonChartScene(projection, {
1093
1106
  width: chartWidth,
1094
1107
  height: chartHeight,
@@ -1096,7 +1109,8 @@ function ComparisonStockChartView({
1096
1109
  cursorY: displayCursorY,
1097
1110
  selectedSymbol: viewState.selectedSymbol,
1098
1111
  colors: chartColors,
1099
- }), [chartColors, chartHeight, chartWidth, displayCursorX, displayCursorY, projection, viewState.selectedSymbol]);
1112
+ marketSession,
1113
+ }), [chartColors, chartHeight, chartWidth, displayCursorX, displayCursorY, marketSession, projection, viewState.selectedSymbol]);
1100
1114
 
1101
1115
  const staticResult = useMemo(() => renderComparisonChart(projection, {
1102
1116
  width: chartWidth,
@@ -1105,7 +1119,8 @@ function ComparisonStockChartView({
1105
1119
  cursorY: null,
1106
1120
  selectedSymbol: viewState.selectedSymbol,
1107
1121
  colors: chartColors,
1108
- }), [chartColors, chartHeight, chartWidth, projection, viewState.selectedSymbol]);
1122
+ marketSession,
1123
+ }), [chartColors, chartHeight, chartWidth, marketSession, projection, viewState.selectedSymbol]);
1109
1124
 
1110
1125
  const interactiveResult = useMemo(() => (
1111
1126
  effectiveRenderer === "kitty" || useCanvasChart
@@ -1117,8 +1132,9 @@ function ComparisonStockChartView({
1117
1132
  cursorY: displayCursorY,
1118
1133
  selectedSymbol: viewState.selectedSymbol,
1119
1134
  colors: chartColors,
1135
+ marketSession,
1120
1136
  })
1121
- ), [chartColors, chartHeight, chartWidth, displayCursorX, displayCursorY, effectiveRenderer, projection, useCanvasChart, viewState.selectedSymbol]);
1137
+ ), [chartColors, chartHeight, chartWidth, displayCursorX, displayCursorY, effectiveRenderer, marketSession, projection, useCanvasChart, viewState.selectedSymbol]);
1122
1138
 
1123
1139
  const result = effectiveRenderer === "kitty" || useCanvasChart ? staticResult : interactiveResult!;
1124
1140
  const timeAxisLabel = result.timeLabels || staticResult.timeLabels;
@@ -1282,7 +1298,14 @@ function ComparisonStockChartView({
1282
1298
  viewState.selectedSymbol,
1283
1299
  bitmapSize.pixelWidth,
1284
1300
  bitmapSize.pixelHeight,
1285
- [chartColors.bgColor, chartColors.gridColor, chartColors.crosshairColor].join(","),
1301
+ [
1302
+ chartColors.bgColor,
1303
+ chartColors.gridColor,
1304
+ chartColors.crosshairColor,
1305
+ chartColors.preMarketBgColor,
1306
+ chartColors.postMarketBgColor,
1307
+ ].join(","),
1308
+ marketSessionKey,
1286
1309
  );
1287
1310
  const cachedBitmap = lastNativeBaseBitmapRef.current?.key === bitmapKey
1288
1311
  ? lastNativeBaseBitmapRef.current.bitmap
@@ -1312,7 +1335,10 @@ function ComparisonStockChartView({
1312
1335
  chartColors.bgColor,
1313
1336
  chartColors.crosshairColor,
1314
1337
  chartColors.gridColor,
1338
+ chartColors.postMarketBgColor,
1339
+ chartColors.preMarketBgColor,
1315
1340
  effectiveRenderer,
1341
+ marketSessionKey,
1316
1342
  nativeSurfaceManager,
1317
1343
  paneId,
1318
1344
  projection,
@@ -1685,9 +1711,10 @@ function ComparisonStockChartView({
1685
1711
  cursorY: null,
1686
1712
  selectedSymbol: viewState.selectedSymbol,
1687
1713
  colors: chartColors,
1714
+ marketSession,
1688
1715
  })
1689
1716
  : null
1690
- ), [canvasBitmapSize?.pixelWidth, canvasProjection, chartColors, chartHeight, chartWidth, viewState.selectedSymbol]);
1717
+ ), [canvasBitmapSize?.pixelWidth, canvasProjection, chartColors, chartHeight, chartWidth, marketSession, viewState.selectedSymbol]);
1691
1718
 
1692
1719
  const canvasBaseBitmapKey = useMemo(() => {
1693
1720
  if (!canvasBitmapSize || !canvasProjection || !hasChartData || isBlockingBody || bodyMessage) return null;
@@ -1697,7 +1724,14 @@ function ComparisonStockChartView({
1697
1724
  viewState.selectedSymbol,
1698
1725
  canvasBitmapSize.pixelWidth,
1699
1726
  canvasBitmapSize.pixelHeight,
1700
- [chartColors.bgColor, chartColors.gridColor, chartColors.crosshairColor].join(","),
1727
+ [
1728
+ chartColors.bgColor,
1729
+ chartColors.gridColor,
1730
+ chartColors.crosshairColor,
1731
+ chartColors.preMarketBgColor,
1732
+ chartColors.postMarketBgColor,
1733
+ ].join(","),
1734
+ marketSessionKey,
1701
1735
  );
1702
1736
  }, [
1703
1737
  bodyMessage,
@@ -1706,8 +1740,11 @@ function ComparisonStockChartView({
1706
1740
  chartColors.bgColor,
1707
1741
  chartColors.crosshairColor,
1708
1742
  chartColors.gridColor,
1743
+ chartColors.postMarketBgColor,
1744
+ chartColors.preMarketBgColor,
1709
1745
  hasChartData,
1710
1746
  isBlockingBody,
1747
+ marketSessionKey,
1711
1748
  symbols.length,
1712
1749
  viewState.selectedSymbol,
1713
1750
  ]);
@@ -1922,7 +1959,7 @@ function ComparisonStockChartView({
1922
1959
  cursorColumn={cursorTimeAxisColumn}
1923
1960
  cursorPixelX={hasDisplayCursor ? displayCursor.pixelX : null}
1924
1961
  cursorDate={cursorTimeAxisDate}
1925
- dates={projection.dates}
1962
+ dates={visibleWindow.dates}
1926
1963
  cursorColor={chartColors.crosshairColor}
1927
1964
  />
1928
1965
  </Box>
@@ -0,0 +1,187 @@
1
+ import type { ChartMarketSession, ChartSessionBackgroundKind, ChartSessionBackgroundSpan } from "./chart-types";
2
+
3
+ const INTRADAY_SESSION_GAP_MS = 6 * 60 * 60_000;
4
+ const MAX_SESSION_BACKGROUND_WINDOW_MS = 10 * 24 * 60 * 60_000;
5
+
6
+ const US_EXTENDED_HOURS_SESSION: ChartMarketSession = {
7
+ timeZone: "America/New_York",
8
+ preMarketStartMinutes: 4 * 60,
9
+ regularStartMinutes: 9 * 60 + 30,
10
+ regularEndMinutes: 16 * 60,
11
+ postMarketEndMinutes: 20 * 60,
12
+ };
13
+
14
+ const US_EXCHANGE_PATTERN = /\b(NASDAQ|NYSE|AMEX|ARCA|BATS|IEX|NMS|XNAS|XNYS|ARCX|XASE|NYSEARCA|NASDAQGS|NASDAQGM|NASDAQCM)\b/i;
15
+ const EQUITY_LIKE_ASSET_CATEGORIES = new Set(["", "STK", "ETF", "ADR", "EQUITY"]);
16
+
17
+ interface MarketSessionSource {
18
+ exchange?: string | null;
19
+ primaryExchange?: string | null;
20
+ currency?: string | null;
21
+ assetCategory?: string | null;
22
+ }
23
+
24
+ interface ZonedDateTime {
25
+ dayKey: string;
26
+ minutes: number;
27
+ }
28
+
29
+ const zonedFormatterCache = new Map<string, Intl.DateTimeFormat>();
30
+
31
+ function getZonedFormatter(timeZone: string): Intl.DateTimeFormat {
32
+ const cached = zonedFormatterCache.get(timeZone);
33
+ if (cached) return cached;
34
+
35
+ const formatter = new Intl.DateTimeFormat("en-US", {
36
+ timeZone,
37
+ hourCycle: "h23",
38
+ year: "numeric",
39
+ month: "2-digit",
40
+ day: "2-digit",
41
+ hour: "2-digit",
42
+ minute: "2-digit",
43
+ });
44
+ zonedFormatterCache.set(timeZone, formatter);
45
+ return formatter;
46
+ }
47
+
48
+ function normalizeDate(value: Date | string | number): Date | null {
49
+ const date = value instanceof Date ? value : new Date(value);
50
+ return Number.isNaN(date.getTime()) ? null : date;
51
+ }
52
+
53
+ function getMinPositiveGapMs(dates: readonly Date[]): number {
54
+ let minGapMs = Number.POSITIVE_INFINITY;
55
+ for (let index = 1; index < dates.length; index += 1) {
56
+ const gapMs = Math.abs(dates[index]!.getTime() - dates[index - 1]!.getTime());
57
+ if (gapMs > 0 && gapMs < minGapMs) {
58
+ minGapMs = gapMs;
59
+ }
60
+ }
61
+ return Number.isFinite(minGapMs) ? minGapMs : 0;
62
+ }
63
+
64
+ function isIntradaySeries(dates: readonly Date[]): boolean {
65
+ if (dates.length < 2) return false;
66
+ const minGapMs = getMinPositiveGapMs(dates);
67
+ return minGapMs > 0 && minGapMs <= INTRADAY_SESSION_GAP_MS;
68
+ }
69
+
70
+ function getDateSpanMs(dates: readonly Date[]): number {
71
+ let minTime = Number.POSITIVE_INFINITY;
72
+ let maxTime = Number.NEGATIVE_INFINITY;
73
+ for (const date of dates) {
74
+ const time = date.getTime();
75
+ if (time < minTime) minTime = time;
76
+ if (time > maxTime) maxTime = time;
77
+ }
78
+ return Number.isFinite(minTime) && Number.isFinite(maxTime) ? maxTime - minTime : 0;
79
+ }
80
+
81
+ function shouldShowSessionBackgrounds(dates: readonly Date[]): boolean {
82
+ return isIntradaySeries(dates) && getDateSpanMs(dates) <= MAX_SESSION_BACKGROUND_WINDOW_MS;
83
+ }
84
+
85
+ function getZonedDateTime(date: Date, timeZone: string): ZonedDateTime | null {
86
+ const values = new Map<string, string>();
87
+ for (const part of getZonedFormatter(timeZone).formatToParts(date)) {
88
+ if (part.type !== "literal") values.set(part.type, part.value);
89
+ }
90
+
91
+ const year = values.get("year");
92
+ const month = values.get("month");
93
+ const day = values.get("day");
94
+ const hour = values.get("hour");
95
+ const minute = values.get("minute");
96
+ if (!year || !month || !day || !hour || !minute) return null;
97
+
98
+ return {
99
+ dayKey: `${year}-${month}-${day}`,
100
+ minutes: Number.parseInt(hour, 10) * 60 + Number.parseInt(minute, 10),
101
+ };
102
+ }
103
+
104
+ function getSessionBackgroundKind(minutes: number, session: ChartMarketSession): ChartSessionBackgroundKind | null {
105
+ if (minutes >= session.preMarketStartMinutes && minutes < session.regularStartMinutes) return "pre";
106
+ if (minutes >= session.regularEndMinutes && minutes < session.postMarketEndMinutes) return "post";
107
+ return null;
108
+ }
109
+
110
+ function isEquityLike(assetCategory: string | null | undefined): boolean {
111
+ return EQUITY_LIKE_ASSET_CATEGORIES.has((assetCategory ?? "").trim().toUpperCase());
112
+ }
113
+
114
+ function hasUsExchange(source: MarketSessionSource): boolean {
115
+ const exchangeText = [
116
+ source.exchange,
117
+ source.primaryExchange,
118
+ ].filter(Boolean).join(" ");
119
+ return US_EXCHANGE_PATTERN.test(exchangeText);
120
+ }
121
+
122
+ function isExplicitNonUsSource(source: MarketSessionSource): boolean {
123
+ const exchangeText = [
124
+ source.exchange,
125
+ source.primaryExchange,
126
+ ].filter(Boolean).join(" ");
127
+ return exchangeText.length > 0 && !US_EXCHANGE_PATTERN.test(exchangeText) && (source.currency ?? "").toUpperCase() !== "USD";
128
+ }
129
+
130
+ export function resolveChartMarketSession(sources: readonly MarketSessionSource[]): ChartMarketSession | null {
131
+ if (sources.length === 0) return null;
132
+ if (sources.some((source) => !isEquityLike(source.assetCategory))) return null;
133
+ if (sources.some(isExplicitNonUsSource)) return null;
134
+
135
+ const hasSupportedSource = sources.some((source) => hasUsExchange(source) || (source.currency ?? "").toUpperCase() === "USD");
136
+ return hasSupportedSource ? US_EXTENDED_HOURS_SESSION : null;
137
+ }
138
+
139
+ export function getChartMarketSessionKey(session: ChartMarketSession | null | undefined): string {
140
+ if (!session) return "session:none";
141
+ return [
142
+ session.timeZone,
143
+ session.preMarketStartMinutes,
144
+ session.regularStartMinutes,
145
+ session.regularEndMinutes,
146
+ session.postMarketEndMinutes,
147
+ ].join(":");
148
+ }
149
+
150
+ export function resolveExtendedHoursBackgroundSpans(
151
+ values: readonly Array<Date | string | number>,
152
+ session: ChartMarketSession | null | undefined,
153
+ ): ChartSessionBackgroundSpan[] {
154
+ if (!session) return [];
155
+
156
+ const dates = values.map(normalizeDate);
157
+ const validDates = dates.filter((date): date is Date => date !== null);
158
+ if (!shouldShowSessionBackgrounds(validDates)) return [];
159
+
160
+ const spans: ChartSessionBackgroundSpan[] = [];
161
+ let active: ChartSessionBackgroundSpan | null = null;
162
+ let activeDayKey: string | null = null;
163
+
164
+ for (let index = 0; index < dates.length; index += 1) {
165
+ const date = dates[index];
166
+ const zoned = date ? getZonedDateTime(date, session.timeZone) : null;
167
+ const kind = zoned ? getSessionBackgroundKind(zoned.minutes, session) : null;
168
+ if (!kind) {
169
+ if (active) spans.push(active);
170
+ active = null;
171
+ activeDayKey = null;
172
+ continue;
173
+ }
174
+
175
+ if (active && active.kind === kind && activeDayKey === zoned!.dayKey) {
176
+ active.endIndex = index;
177
+ continue;
178
+ }
179
+
180
+ if (active) spans.push(active);
181
+ active = { kind, startIndex: index, endIndex: index };
182
+ activeDayKey = zoned!.dayKey;
183
+ }
184
+
185
+ if (active) spans.push(active);
186
+ return spans;
187
+ }
@@ -2,7 +2,7 @@ import { type PixelResolution } from "../../../ui";
2
2
  import { measurePerf } from "../../../utils/perf-marks";
3
3
  import { computeGridLines, type ChartScene } from "../chart-renderer";
4
4
  import type { ComparisonChartScene } from "../comparison-chart-renderer";
5
- import type { ChartRenderMode } from "../chart-types";
5
+ import type { ChartColors, ChartRenderMode, ChartSessionBackgroundSpan } from "../chart-types";
6
6
 
7
7
  interface RgbaColor {
8
8
  r: number;
@@ -284,6 +284,47 @@ function projectChartX(index: number, count: number, width: number, mode: ChartR
284
284
  return projectX(index, count, horizontalPad, Math.max(width - 1 - horizontalPad, horizontalPad));
285
285
  }
286
286
 
287
+ function getProjectedPointBand(
288
+ index: number,
289
+ count: number,
290
+ width: number,
291
+ projectIndex: (targetIndex: number) => number,
292
+ ): PixelRect {
293
+ const currentX = projectIndex(index);
294
+ const previousX = index > 0 ? projectIndex(index - 1) : currentX;
295
+ const nextX = index < count - 1 ? projectIndex(index + 1) : currentX;
296
+ const left = index === 0 ? 0 : Math.floor((previousX + currentX) / 2) + 1;
297
+ const right = index === count - 1 ? Math.max(width - 1, 0) : Math.floor((currentX + nextX) / 2);
298
+ return {
299
+ x: clamp(left, 0, Math.max(width - 1, 0)),
300
+ y: 0,
301
+ width: Math.max(clamp(right, 0, Math.max(width - 1, 0)) - clamp(left, 0, Math.max(width - 1, 0)) + 1, 1),
302
+ height: 1,
303
+ };
304
+ }
305
+
306
+ function drawSessionBackgroundSpans(
307
+ data: Uint8Array,
308
+ width: number,
309
+ height: number,
310
+ spans: readonly ChartSessionBackgroundSpan[],
311
+ pointCount: number,
312
+ top: number,
313
+ bottom: number,
314
+ colors: Pick<ChartColors, "preMarketBgColor" | "postMarketBgColor">,
315
+ projectIndex: (targetIndex: number) => number,
316
+ ) {
317
+ if (spans.length === 0 || pointCount === 0) return;
318
+ for (const span of spans) {
319
+ const startIndex = clamp(span.startIndex, 0, pointCount - 1);
320
+ const endIndex = clamp(span.endIndex, startIndex, pointCount - 1);
321
+ const startBand = getProjectedPointBand(startIndex, pointCount, width, projectIndex);
322
+ const endBand = getProjectedPointBand(endIndex, pointCount, width, projectIndex);
323
+ const color = parseHex(span.kind === "pre" ? colors.preMarketBgColor : colors.postMarketBgColor, 1);
324
+ fillRect(data, width, height, startBand.x, top, endBand.x + endBand.width - 1, bottom, color, 1);
325
+ }
326
+ }
327
+
287
328
  function projectY(value: number, min: number, max: number, top: number, bottom: number): number {
288
329
  const range = max - min || 1;
289
330
  return lerp(bottom, top, (value - min) / range);
@@ -496,7 +537,19 @@ export function renderNativeChartBase(scene: ChartScene, pixelWidth: number, pix
496
537
  }
497
538
 
498
539
  const layout = getChartPixelLayout(scene, pixelWidth, pixelHeight);
540
+ drawSessionBackgroundSpans(
541
+ pixels,
542
+ pixelWidth,
543
+ pixelHeight,
544
+ scene.sessionBackgroundSpans,
545
+ scene.points.length,
546
+ layout.plotTop,
547
+ scene.showVolume ? layout.volumeBottom : layout.plotBottom,
548
+ scene.colors,
549
+ (index) => projectX(index, scene.points.length, 0, Math.max(pixelWidth - 1, 0)),
550
+ );
499
551
  drawPriceGrid(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
552
+ drawIndicatorOverlays(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
500
553
 
501
554
  switch (scene.mode) {
502
555
  case "area":
@@ -514,7 +567,6 @@ export function renderNativeChartBase(scene: ChartScene, pixelWidth: number, pix
514
567
  break;
515
568
  }
516
569
 
517
- drawIndicatorOverlays(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
518
570
  drawVolume(pixels, pixelWidth, pixelHeight, scene, layout.volumeTop, layout.volumeBottom);
519
571
 
520
572
  return { width: pixelWidth, height: pixelHeight, pixels };
@@ -603,6 +655,17 @@ export function renderNativeComparisonChartBase(
603
655
  }
604
656
 
605
657
  const layout = getComparisonPixelLayout(pixelWidth, pixelHeight);
658
+ drawSessionBackgroundSpans(
659
+ pixels,
660
+ pixelWidth,
661
+ pixelHeight,
662
+ scene.sessionBackgroundSpans,
663
+ scene.dates.length,
664
+ layout.plotTop,
665
+ layout.plotBottom,
666
+ scene.colors,
667
+ (index) => projectX(index, Math.max(scene.dates.length, 1), 0, pixelWidth - 1),
668
+ );
606
669
  drawPriceGrid(
607
670
  pixels,
608
671
  pixelWidth,
@@ -204,6 +204,12 @@ function getFrameLineContaining(frame: string, text: string): string {
204
204
  return frame.split("\n").find((line) => line.includes(text)) ?? "";
205
205
  }
206
206
 
207
+ const MONTH_LABEL_PATTERN = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/;
208
+
209
+ function getTimeAxisLine(frame: string): string {
210
+ return frame.split("\n").find((line) => MONTH_LABEL_PATTERN.test(line)) ?? "";
211
+ }
212
+
207
213
  function getFrameRowContaining(frame: string, text: string): number {
208
214
  return frame.split("\n").findIndex((line) => line.includes(text));
209
215
  }
@@ -575,6 +581,96 @@ describe("StockChart renderer switching", () => {
575
581
  expect(frame).not.toContain("view:");
576
582
  });
577
583
 
584
+ test("keeps the visible range stable when indicator warmup expands history", async () => {
585
+ const symbol = "AAPL";
586
+ const config = makeChartConfig(symbol);
587
+ config.layout.instances = config.layout.instances.map((instance) => (
588
+ instance.instanceId === TEST_PANE_ID
589
+ ? {
590
+ ...instance,
591
+ settings: {
592
+ chartRangePreset: "1M",
593
+ chartResolution: "1d",
594
+ },
595
+ }
596
+ : instance
597
+ ));
598
+ config.layouts = [{ name: "Default", layout: cloneLayout(config.layout) }];
599
+
600
+ const ticker = makeTicker(symbol);
601
+ const fullHistory = makeHistory(260);
602
+ const monthHistory = fullHistory.slice(-21);
603
+ const yearHistory = fullHistory.slice(-252);
604
+ const historyForRange = (range: string) => range === "1Y" ? yearHistory : monthHistory;
605
+ const provider = {
606
+ ...makeProvider({ [symbol]: fullHistory }),
607
+ getPriceHistory: async (_requestSymbol: string, _exchange: string, range: string) => historyForRange(range),
608
+ getPriceHistoryForResolution: async (_requestSymbol: string, _exchange: string, range: string) => historyForRange(range),
609
+ getDetailedPriceHistory: async (
610
+ _requestSymbol: string,
611
+ _exchange: string,
612
+ startDate: Date,
613
+ endDate: Date,
614
+ ) => yearHistory.filter((point) => {
615
+ const date = point.date instanceof Date ? point.date : new Date(point.date);
616
+ return date >= startDate && date <= endDate;
617
+ }),
618
+ } satisfies DataProvider;
619
+ sharedCoordinator = new MarketDataCoordinator(provider);
620
+ setSharedMarketDataCoordinator(sharedCoordinator);
621
+ setSharedMarketDataForTests(provider);
622
+ const financials: TickerFinancials = {
623
+ annualStatements: [],
624
+ quarterlyStatements: [],
625
+ priceHistory: monthHistory,
626
+ };
627
+
628
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
629
+ testSetup = await createTestRenderer({ width: 120, height: 32 });
630
+ root = createRoot(testSetup.renderer);
631
+ act(() => {
632
+ root!.render(
633
+ <ChartHarness
634
+ config={config}
635
+ ticker={ticker}
636
+ financials={financials}
637
+ />,
638
+ );
639
+ });
640
+
641
+ await flushFrames(6);
642
+ await flushDebouncedChartRequests();
643
+ const beforeAxis = getTimeAxisLine(testSetup.captureCharFrame());
644
+ expect(beforeAxis).not.toBe("");
645
+
646
+ const nextConfig = {
647
+ ...config,
648
+ layout: cloneLayout(config.layout),
649
+ };
650
+ nextConfig.layout.instances = nextConfig.layout.instances.map((instance) => (
651
+ instance.instanceId === TEST_PANE_ID
652
+ ? {
653
+ ...instance,
654
+ settings: {
655
+ ...(instance.settings ?? {}),
656
+ indicators: { sma: [200] },
657
+ },
658
+ }
659
+ : instance
660
+ ));
661
+ nextConfig.layouts = [{ name: "Default", layout: cloneLayout(nextConfig.layout) }];
662
+
663
+ act(() => {
664
+ harnessDispatch!({ type: "SET_CONFIG", config: nextConfig });
665
+ });
666
+
667
+ await flushFrames(4);
668
+ await flushDebouncedChartRequests();
669
+ await flushFrames(4);
670
+
671
+ expect(getTimeAxisLine(testSetup.captureCharFrame())).toBe(beforeAxis);
672
+ });
673
+
578
674
  test("chooses supported auto detail resolutions as the visible window changes", async () => {
579
675
  const symbol = "AAPL";
580
676
  const config = makeChartConfig(symbol);