gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -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);
@@ -128,6 +128,7 @@ import type { PricePoint, TickerFinancials } from "../../types/financials";
128
128
  import type { TickerRecord } from "../../types/ticker";
129
129
  import { TimeAxisLabel } from "./time-axis-label";
130
130
  import { PriceAxisLabels } from "./price-axis-labels";
131
+ import { getChartMarketSessionKey, resolveChartMarketSession } from "./market-session";
131
132
 
132
133
  const MODE_CHIPS: Record<ChartRenderMode, string> = {
133
134
  area: "A",
@@ -1086,6 +1087,7 @@ function buildNativeBitmapKey(
1086
1087
  showVolume: boolean,
1087
1088
  paletteId: string,
1088
1089
  indicatorKey: string,
1090
+ marketSessionKey: string,
1089
1091
  ): string {
1090
1092
  const fingerprint = points
1091
1093
  .map((point) => {
@@ -1093,7 +1095,7 @@ function buildNativeBitmapKey(
1093
1095
  return `${date}:${point.open}:${point.high}:${point.low}:${point.close}:${point.volume ?? 0}`;
1094
1096
  })
1095
1097
  .join("|");
1096
- return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, indicatorKey, fingerprint].join("::");
1098
+ return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, indicatorKey, marketSessionKey, fingerprint].join("::");
1097
1099
  }
1098
1100
 
1099
1101
  function buildNativeCrosshairBitmapKey(
@@ -1715,7 +1717,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1715
1717
  !compact && instrumentRef
1716
1718
  ? {
1717
1719
  instrument: instrumentRef,
1718
- bufferRange: indicatorBufferRange,
1720
+ bufferRange: viewState.bufferRange,
1719
1721
  granularity: "range",
1720
1722
  }
1721
1723
  : null,
@@ -3044,6 +3046,14 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3044
3046
  themeColors.text,
3045
3047
  themeColors.textDim,
3046
3048
  ]);
3049
+ const marketSession = useMemo(() => resolveChartMarketSession(ticker
3050
+ ? [{
3051
+ exchange: ticker.metadata.exchange,
3052
+ currency: ticker.metadata.currency,
3053
+ assetCategory: ticker.metadata.assetCategory,
3054
+ }]
3055
+ : []), [ticker]);
3056
+ const marketSessionKey = useMemo(() => getChartMarketSessionKey(marketSession), [marketSession]);
3047
3057
 
3048
3058
  const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
3049
3059
  const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
@@ -3071,7 +3081,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3071
3081
  axisMode,
3072
3082
  colors: chartColors,
3073
3083
  timeAxisDates,
3074
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, timeAxisDates, volumeHeight]);
3084
+ marketSession,
3085
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, marketSession, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, timeAxisDates, volumeHeight]);
3075
3086
  const displayScene = useMemo(() => buildChartScene(projection.points, {
3076
3087
  width: chartWidth,
3077
3088
  height: chartHeight,
@@ -3084,7 +3095,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3084
3095
  colors: chartColors,
3085
3096
  timeAxisDates,
3086
3097
  indicators,
3087
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3098
+ marketSession,
3099
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3088
3100
  const snappedSelectionCursorX = selectionScene
3089
3101
  ? getPointTerminalColumn(selectionScene.activeIdx, projection.points.length, chartWidth, projection.effectiveMode)
3090
3102
  : null;
@@ -3130,7 +3142,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3130
3142
  colors: chartColors,
3131
3143
  timeAxisDates,
3132
3144
  indicators,
3133
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3145
+ marketSession,
3146
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3134
3147
 
3135
3148
  const staticResult = useMemo(() => renderChart(projection.points, {
3136
3149
  width: chartWidth,
@@ -3146,7 +3159,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3146
3159
  colors: chartColors,
3147
3160
  timeAxisDates,
3148
3161
  indicators,
3149
- }), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3162
+ marketSession,
3163
+ }), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3150
3164
 
3151
3165
  const interactiveResult = useMemo(() => (
3152
3166
  effectiveRenderer === "kitty" || useCanvasChart
@@ -3165,8 +3179,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3165
3179
  colors: chartColors,
3166
3180
  timeAxisDates,
3167
3181
  indicators,
3182
+ marketSession,
3168
3183
  })
3169
- ), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, useCanvasChart, volumeHeight]);
3184
+ ), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, useCanvasChart, volumeHeight]);
3170
3185
 
3171
3186
  const result = effectiveRenderer === "kitty" || useCanvasChart ? staticResult : interactiveResult!;
3172
3187
 
@@ -3300,8 +3315,11 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3300
3315
  chartColors.volumeDown,
3301
3316
  chartColors.candleUp,
3302
3317
  chartColors.candleDown,
3318
+ chartColors.preMarketBgColor,
3319
+ chartColors.postMarketBgColor,
3303
3320
  ].join(","),
3304
3321
  indicatorRenderKey,
3322
+ marketSessionKey,
3305
3323
  );
3306
3324
  const cachedBitmap = lastNativeBaseBitmapRef.current?.key === bitmapKey
3307
3325
  ? lastNativeBaseBitmapRef.current.bitmap
@@ -3331,11 +3349,14 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3331
3349
  chartColors.fillColor,
3332
3350
  chartColors.gridColor,
3333
3351
  chartColors.lineColor,
3352
+ chartColors.postMarketBgColor,
3353
+ chartColors.preMarketBgColor,
3334
3354
  chartColors.volumeDown,
3335
3355
  chartColors.volumeUp,
3336
3356
  compact,
3337
3357
  effectiveRenderer,
3338
3358
  indicatorRenderKey,
3359
+ marketSessionKey,
3339
3360
  nativeBaseScene,
3340
3361
  nativeBaseSurfaceId,
3341
3362
  nativeSurfaceManager,
@@ -3829,9 +3850,10 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3829
3850
  colors: chartColors,
3830
3851
  timeAxisDates,
3831
3852
  indicators: canvasIndicators,
3853
+ marketSession,
3832
3854
  })
3833
3855
  : null
3834
- ), [axisMode, canvasIndicators, canvasProjection, chartColors, chartHeight, compact, showVolume, timeAxisDates, volumeHeight]);
3856
+ ), [axisMode, canvasIndicators, canvasProjection, chartColors, chartHeight, compact, marketSession, showVolume, timeAxisDates, volumeHeight]);
3835
3857
 
3836
3858
  const canvasBaseBitmapKey = useMemo(() => {
3837
3859
  if (!canvasBitmapSize || !canvasProjection || !hasHistory || isBlockingBody || bodyMessage) return null;
@@ -3850,8 +3872,11 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3850
3872
  chartColors.volumeDown,
3851
3873
  chartColors.candleUp,
3852
3874
  chartColors.candleDown,
3875
+ chartColors.preMarketBgColor,
3876
+ chartColors.postMarketBgColor,
3853
3877
  ].join(","),
3854
3878
  canvasIndicatorRenderKey,
3879
+ marketSessionKey,
3855
3880
  );
3856
3881
  }, [
3857
3882
  bodyMessage,
@@ -3863,11 +3888,14 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3863
3888
  chartColors.fillColor,
3864
3889
  chartColors.gridColor,
3865
3890
  chartColors.lineColor,
3891
+ chartColors.postMarketBgColor,
3892
+ chartColors.preMarketBgColor,
3866
3893
  chartColors.volumeDown,
3867
3894
  chartColors.volumeUp,
3868
3895
  compact,
3869
3896
  hasHistory,
3870
3897
  isBlockingBody,
3898
+ marketSessionKey,
3871
3899
  showVolume,
3872
3900
  ]);
3873
3901
 
@@ -23,6 +23,51 @@ function clamp(value: number, min: number, max: number): number {
23
23
  return Math.max(min, Math.min(max, value));
24
24
  }
25
25
 
26
+ function normalizeDate(value: Date | string | number): Date | null {
27
+ const date = value instanceof Date ? value : new Date(value);
28
+ return Number.isFinite(date.getTime()) ? date : null;
29
+ }
30
+
31
+ export function resolveCursorDateFromAxis({
32
+ dates,
33
+ width,
34
+ cursorColumn,
35
+ cursorPixelX,
36
+ cellWidthPx,
37
+ }: {
38
+ dates: Array<Date | string | number>;
39
+ width: number;
40
+ cursorColumn: number | null;
41
+ cursorPixelX?: number | null;
42
+ cellWidthPx?: number | null;
43
+ }): Date | null {
44
+ const normalizedDates = dates
45
+ .map(normalizeDate)
46
+ .filter((date): date is Date => date !== null);
47
+ if (normalizedDates.length === 0) return null;
48
+ if (normalizedDates.length === 1) return normalizedDates[0]!;
49
+
50
+ let ratio: number | null = null;
51
+ if (
52
+ cursorPixelX !== null
53
+ && cursorPixelX !== undefined
54
+ && Number.isFinite(cursorPixelX)
55
+ && cellWidthPx !== null
56
+ && cellWidthPx !== undefined
57
+ && Number.isFinite(cellWidthPx)
58
+ && cellWidthPx > 0
59
+ ) {
60
+ const pixelWidth = Math.max(width * cellWidthPx, 1);
61
+ ratio = clamp(cursorPixelX / Math.max(pixelWidth - 1, 1), 0, 1);
62
+ } else if (cursorColumn !== null && Number.isFinite(cursorColumn) && width > 1) {
63
+ ratio = clamp(cursorColumn / Math.max(width - 1, 1), 0, 1);
64
+ }
65
+
66
+ if (ratio === null) return null;
67
+ const index = Math.round(ratio * (normalizedDates.length - 1));
68
+ return normalizedDates[clamp(index, 0, normalizedDates.length - 1)] ?? null;
69
+ }
70
+
26
71
  export function buildCursorTimeAxisOverlay({
27
72
  segments,
28
73
  width,
@@ -61,13 +106,22 @@ export function TimeAxisLabel({
61
106
  cursorColor,
62
107
  }: TimeAxisLabelProps) {
63
108
  const { cellWidthPx = 8, fractionalViewport = false } = useUiCapabilities();
109
+ const resolvedCursorDate = useMemo(() => {
110
+ return resolveCursorDateFromAxis({
111
+ dates,
112
+ width,
113
+ cursorColumn,
114
+ cursorPixelX: fractionalViewport ? cursorPixelX : null,
115
+ cellWidthPx,
116
+ }) ?? cursorDate;
117
+ }, [cellWidthPx, cursorColumn, cursorDate, cursorPixelX, dates, fractionalViewport, width]);
64
118
  const segments = useMemo(() => buildCursorTimeAxisSegments({
65
119
  timeLabels,
66
120
  width,
67
121
  cursorColumn,
68
- cursorDate,
122
+ cursorDate: resolvedCursorDate,
69
123
  dates,
70
- }), [cursorColumn, cursorDate, dates, timeLabels, width]);
124
+ }), [cursorColumn, dates, resolvedCursorDate, timeLabels, width]);
71
125
  const overlay = useMemo(() => buildCursorTimeAxisOverlay({
72
126
  segments,
73
127
  width,
@@ -1276,7 +1276,7 @@ describe("CommandBar", () => {
1276
1276
  );
1277
1277
 
1278
1278
  await testSetup.renderOnce();
1279
- const frame = await waitForFrameToContain("DES");
1279
+ const frame = await waitForFrameToContain("Exact Match");
1280
1280
  const resultLines = frame
1281
1281
  .split("\n")
1282
1282
  .filter((line) => /\b(AMD|AMDL|AMUU|DES)\b/.test(line) && /\b(NASDAQ|ASX)\b/.test(line));