gloomberb 0.7.2 → 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 (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. 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;
@@ -247,20 +247,84 @@ function projectX(index: number, count: number, left: number, right: number): nu
247
247
  return lerp(left, right, index / (count - 1));
248
248
  }
249
249
 
250
+ function isOhlcLikeMode(mode: ChartRenderMode): boolean {
251
+ return mode === "ohlc" || mode === "hlc";
252
+ }
253
+
254
+ function isHighLowMode(mode: ChartRenderMode): boolean {
255
+ return mode === "candles" || isOhlcLikeMode(mode);
256
+ }
257
+
258
+ function getNativeBarMetrics(count: number, width: number, mode: Extract<ChartRenderMode, "candles" | "ohlc" | "hlc">) {
259
+ const spacing = width / Math.max(count, 1);
260
+ if (mode === "candles") {
261
+ return {
262
+ bodyWidth: clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2)),
263
+ tickLength: 0,
264
+ stemThickness: 1.2,
265
+ };
266
+ }
267
+
268
+ return {
269
+ bodyWidth: 0,
270
+ tickLength: clamp(spacing * 0.5, 3, Math.max(spacing * 0.62, 3)),
271
+ stemThickness: clamp(spacing * 0.12, 2, 3.2),
272
+ };
273
+ }
274
+
250
275
  function projectChartX(index: number, count: number, width: number, mode: ChartRenderMode): number {
251
- if (mode !== "candles" && mode !== "ohlc") {
276
+ if (!isHighLowMode(mode)) {
252
277
  return projectX(index, count, 0, Math.max(width - 1, 0));
253
278
  }
254
279
 
255
- const spacing = width / Math.max(count, 1);
256
- const bodyWidth = clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2));
257
- const tickLength = clamp(spacing * 0.34, 2, Math.max(spacing * 0.48, 2));
258
- const horizontalPad = mode === "ohlc"
259
- ? Math.ceil(Math.max(tickLength - 1, 0))
280
+ const { bodyWidth, tickLength, stemThickness } = getNativeBarMetrics(count, width, mode);
281
+ const horizontalPad = isOhlcLikeMode(mode)
282
+ ? Math.ceil(Math.max(tickLength - 1, stemThickness / 2, 0))
260
283
  : Math.ceil(bodyWidth / 2);
261
284
  return projectX(index, count, horizontalPad, Math.max(width - 1 - horizontalPad, horizontalPad));
262
285
  }
263
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
+
264
328
  function projectY(value: number, min: number, max: number, top: number, bottom: number): number {
265
329
  const range = max - min || 1;
266
330
  return lerp(bottom, top, (value - min) / range);
@@ -332,13 +396,11 @@ function drawCandles(
332
396
  scene: ChartScene,
333
397
  top: number,
334
398
  bottom: number,
335
- mode: Extract<ChartRenderMode, "candles" | "ohlc">,
399
+ mode: Extract<ChartRenderMode, "candles" | "ohlc" | "hlc">,
336
400
  ) {
337
- const spacing = width / Math.max(scene.points.length, 1);
338
- const bodyWidth = clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2));
339
- const tickLength = clamp(spacing * 0.34, 2, Math.max(spacing * 0.48, 2));
340
- const horizontalPad = mode === "ohlc"
341
- ? Math.ceil(Math.max(tickLength - 1, 0))
401
+ const { bodyWidth, tickLength, stemThickness } = getNativeBarMetrics(scene.points.length, width, mode);
402
+ const horizontalPad = isOhlcLikeMode(mode)
403
+ ? Math.ceil(Math.max(tickLength - 1, stemThickness / 2, 0))
342
404
  : Math.ceil(bodyWidth / 2);
343
405
  const plotLeft = horizontalPad;
344
406
  const plotRight = Math.max(width - 1 - horizontalPad, plotLeft);
@@ -355,10 +417,12 @@ function drawCandles(
355
417
  const wickColor = parseHex(isUp ? scene.colors.wickUp : scene.colors.wickDown, 0.92);
356
418
  const bodyColor = parseHex(isUp ? scene.colors.candleUp : scene.colors.candleDown, 1);
357
419
 
358
- if (mode === "ohlc") {
359
- drawLine(data, width, height, x, highY, x, lowY, wickColor, 1.2);
360
- drawLine(data, width, height, x - tickLength, openY, x, openY, bodyColor, 1.4);
361
- drawLine(data, width, height, x, closeY, x + tickLength, closeY, bodyColor, 1.4);
420
+ if (isOhlcLikeMode(mode)) {
421
+ drawLine(data, width, height, x, highY, x, lowY, wickColor, stemThickness);
422
+ if (mode === "ohlc") {
423
+ drawLine(data, width, height, x - tickLength, openY, x, openY, bodyColor, Math.max(stemThickness, 1.4));
424
+ }
425
+ drawLine(data, width, height, x, closeY, x + tickLength, closeY, bodyColor, Math.max(stemThickness, 1.4));
362
426
  continue;
363
427
  }
364
428
 
@@ -395,7 +459,7 @@ function drawVolume(
395
459
  if (heightRatio <= 0) continue;
396
460
  const x = projectX(index, scene.points.length, 0, width - 1);
397
461
  const barTop = lerp(bottom, top, heightRatio);
398
- const isUp = scene.mode === "candles" || scene.mode === "ohlc"
462
+ const isUp = isHighLowMode(scene.mode)
399
463
  ? point.close >= point.open
400
464
  : index === 0 || point.close >= scene.points[index - 1]!.close;
401
465
  const color = parseHex(isUp ? scene.colors.volumeUp : scene.colors.volumeDown, 0.6);
@@ -473,7 +537,19 @@ export function renderNativeChartBase(scene: ChartScene, pixelWidth: number, pix
473
537
  }
474
538
 
475
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
+ );
476
551
  drawPriceGrid(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
552
+ drawIndicatorOverlays(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
477
553
 
478
554
  switch (scene.mode) {
479
555
  case "area":
@@ -486,9 +562,11 @@ export function renderNativeChartBase(scene: ChartScene, pixelWidth: number, pix
486
562
  case "ohlc":
487
563
  drawCandles(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom, "ohlc");
488
564
  break;
565
+ case "hlc":
566
+ drawCandles(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom, "hlc");
567
+ break;
489
568
  }
490
569
 
491
- drawIndicatorOverlays(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
492
570
  drawVolume(pixels, pixelWidth, pixelHeight, scene, layout.volumeTop, layout.volumeBottom);
493
571
 
494
572
  return { width: pixelWidth, height: pixelHeight, pixels };
@@ -577,6 +655,17 @@ export function renderNativeComparisonChartBase(
577
655
  }
578
656
 
579
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
+ );
580
669
  drawPriceGrid(
581
670
  pixels,
582
671
  pixelWidth,
@@ -25,7 +25,6 @@ function clamp(value: number, min: number, max: number): number {
25
25
 
26
26
  export function buildCursorPriceAxisOverlay({
27
27
  axisWidth,
28
- axisSectionWidth,
29
28
  height,
30
29
  cursorPixelY,
31
30
  cursorLabel,
@@ -40,7 +39,7 @@ export function buildCursorPriceAxisOverlay({
40
39
  }): CursorPriceAxisOverlay {
41
40
  const labelText = cursorLabel === null
42
41
  ? null
43
- : formatAxisCell(cursorLabel, axisWidth).padEnd(axisSectionWidth);
42
+ : formatAxisCell(cursorLabel, axisWidth).trimStart();
44
43
  const pixelHeight = Math.max(height * cellHeightPx, 1);
45
44
  let topPercent: number | null = null;
46
45
 
@@ -73,6 +72,20 @@ export function PriceAxisLabels({
73
72
  cellHeightPx,
74
73
  }), [axisSectionWidth, axisWidth, cellHeightPx, cursorLabel, cursorPixelY, height]);
75
74
  const usePixelOverlay = fractionalViewport && overlay.labelText !== null && overlay.topPercent !== null;
75
+ const axisPaddingWidth = Math.max(0, axisSectionWidth - axisWidth);
76
+ const axisLabelJustify = fractionalViewport ? "flex-start" : "flex-end";
77
+ const renderAxisLabel = (label: string | null, fg: string) => (
78
+ fractionalViewport ? (
79
+ <Box flexDirection="row" width={axisSectionWidth} height={1}>
80
+ <Box flexDirection="row" width={axisWidth} justifyContent={axisLabelJustify} overflow="hidden">
81
+ {label ? <Text fg={fg}>{formatAxisCell(label, axisWidth).trimStart()}</Text> : null}
82
+ </Box>
83
+ {axisPaddingWidth > 0 ? <Box width={axisPaddingWidth} /> : null}
84
+ </Box>
85
+ ) : (
86
+ <Text fg={fg}>{formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}</Text>
87
+ )
88
+ );
76
89
 
77
90
  return (
78
91
  <Box
@@ -86,16 +99,16 @@ export function PriceAxisLabels({
86
99
  const isCursorRow = !usePixelOverlay && cursorLabel !== null && cursorRow === row;
87
100
  const label = isCursorRow ? cursorLabel : (axisLabels.get(row) ?? null);
88
101
  return (
89
- <Text key={row} fg={isCursorRow ? cursorColor : colors.textDim}>
90
- {formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}
91
- </Text>
102
+ <Box key={row} height={1}>
103
+ {renderAxisLabel(label, isCursorRow ? cursorColor : colors.textDim)}
104
+ </Box>
92
105
  );
93
106
  })}
94
107
  {usePixelOverlay ? (
95
- <Text
108
+ <Box
96
109
  width={axisSectionWidth}
97
- fg={cursorColor}
98
110
  bg={colors.bg}
111
+ flexDirection="row"
99
112
  style={{
100
113
  position: "absolute",
101
114
  left: 0,
@@ -106,8 +119,11 @@ export function PriceAxisLabels({
106
119
  zIndex: 1,
107
120
  }}
108
121
  >
109
- {overlay.labelText}
110
- </Text>
122
+ <Box flexDirection="row" width={axisWidth} justifyContent={axisLabelJustify} overflow="hidden">
123
+ <Text fg={cursorColor}>{overlay.labelText}</Text>
124
+ </Box>
125
+ {axisPaddingWidth > 0 ? <Box width={axisPaddingWidth} /> : null}
126
+ </Box>
111
127
  ) : null}
112
128
  </Box>
113
129
  );
@@ -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
  }
@@ -382,7 +388,7 @@ describe("StockChart renderer switching", () => {
382
388
  const manager = getNativeSurfaceManager(testSetup.renderer as never) as unknown as {
383
389
  surfaces: Map<string, { snapshot: { rect: { height: number } } }>;
384
390
  };
385
- expect(manager.surfaces.get("chart-surface:ticker-detail:test:full:base")?.snapshot.rect.height).toBe(11);
391
+ expect(manager.surfaces.get("chart-surface:ticker-detail:test:full:base")?.snapshot.rect.height).toBe(12);
386
392
  });
387
393
 
388
394
  test("moves candle OHLC summary into the pane footer", async () => {
@@ -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);