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.
- package/package.json +1 -1
- package/src/app.tsx +59 -30
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +26 -2
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/layout/shell.tsx +69 -21
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/holders/index.test.ts +30 -1
- package/src/plugins/builtin/holders/index.tsx +7 -3
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/pane-manager.test.ts +31 -0
- package/src/plugins/pane-manager.ts +32 -6
- package/src/renderers/electrobun/bun/index.ts +83 -3
- package/src/renderers/electrobun/shared/protocol.ts +8 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
- package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
- package/src/renderers/electrobun/view/main.tsx +38 -24
- package/src/renderers/electrobun/view/styles.css +48 -4
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +26 -10
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.ts +72 -1
- package/src/version.ts +1 -1
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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("
|
|
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));
|