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