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.
- package/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- 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 +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -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 +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- 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 +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- 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 +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- 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/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -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.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -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;
|
|
@@ -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
|
|
276
|
+
if (!isHighLowMode(mode)) {
|
|
252
277
|
return projectX(index, count, 0, Math.max(width - 1, 0));
|
|
253
278
|
}
|
|
254
279
|
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
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
|
|
338
|
-
const
|
|
339
|
-
|
|
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
|
|
359
|
-
drawLine(data, width, height, x, highY, x, lowY, wickColor,
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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).
|
|
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
|
-
<
|
|
90
|
-
{
|
|
91
|
-
</
|
|
102
|
+
<Box key={row} height={1}>
|
|
103
|
+
{renderAxisLabel(label, isCursorRow ? cursorColor : colors.textDim)}
|
|
104
|
+
</Box>
|
|
92
105
|
);
|
|
93
106
|
})}
|
|
94
107
|
{usePixelOverlay ? (
|
|
95
|
-
<
|
|
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
|
-
{
|
|
110
|
-
|
|
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(
|
|
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);
|