gloomberb 0.10.3 → 0.10.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 +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +64 -3
- package/src/i18n/ko.ts +64 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +64 -3
- package/src/i18n/zh-tw.ts +64 -3
- package/src/market-data/coordinator/quotes.ts +63 -1
- package/src/market-data/coordinator/subscriptions.test.ts +33 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -124
- package/src/plugins/builtin/options/footer.ts +32 -90
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/view.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +124 -1
- package/src/state/hooks/quote-streaming.ts +90 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -105,12 +105,64 @@ describe("resolveChartSpecData", () => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
expect(genericHistoryCalls).toBe(0);
|
|
108
|
-
|
|
108
|
+
// The series keeps its legend slot with no observations so the chart and the
|
|
109
|
+
// series editor agree on what the user added.
|
|
110
|
+
expect(result.series.map((entry) => [entry.id, entry.points.length])).toEqual([["price", 0]]);
|
|
109
111
|
expect(result.errors).toEqual([
|
|
110
112
|
"price: Requested 5m price history is unavailable for TEST. Choose Auto or a supported interval.",
|
|
111
113
|
]);
|
|
112
114
|
});
|
|
113
115
|
|
|
116
|
+
test("falls back from an unsupported manual interval while exposing source capabilities", async () => {
|
|
117
|
+
const requestedResolutions: string[] = [];
|
|
118
|
+
const provider = createTestDataProvider({
|
|
119
|
+
getTickerFinancials: async () => emptyFinancials(),
|
|
120
|
+
getChartResolutionSupport: () => [
|
|
121
|
+
{ resolution: "15m", maxRange: "1M" },
|
|
122
|
+
{ resolution: "1h", maxRange: "3M" },
|
|
123
|
+
],
|
|
124
|
+
getPriceHistoryForResolution: async (_symbol, _exchange, _range, resolution) => {
|
|
125
|
+
requestedResolutions.push(resolution);
|
|
126
|
+
return [{ date: new Date("2026-07-30T15:00:00Z"), close: 100 }];
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const spec: ChartSpec = {
|
|
130
|
+
version: CHART_SPEC_VERSION,
|
|
131
|
+
viewport: { range: "1M", resolution: "45m" },
|
|
132
|
+
panels: [{ id: "main" }],
|
|
133
|
+
series: [{
|
|
134
|
+
id: "price",
|
|
135
|
+
source: {
|
|
136
|
+
kind: "security",
|
|
137
|
+
instrument: { symbol: "TEST", exchange: "NASDAQ" },
|
|
138
|
+
fieldId: "market.close",
|
|
139
|
+
},
|
|
140
|
+
style: "line",
|
|
141
|
+
transform: "raw",
|
|
142
|
+
axis: "left",
|
|
143
|
+
panelId: "main",
|
|
144
|
+
interpolation: "none",
|
|
145
|
+
}],
|
|
146
|
+
studies: [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = await resolveChartSpecData(spec, {
|
|
150
|
+
dataProvider: provider,
|
|
151
|
+
now: new Date("2026-07-30T16:00:00Z"),
|
|
152
|
+
loadFredSeries: async () => fredLoad(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(requestedResolutions).toEqual(["15m"]);
|
|
156
|
+
expect(result.errors).toEqual([]);
|
|
157
|
+
expect(result.warnings).toContain(
|
|
158
|
+
"45M data is unavailable for this range. Auto resolution was used instead.",
|
|
159
|
+
);
|
|
160
|
+
expect(result.resolutionSupport).toEqual([
|
|
161
|
+
{ resolution: "15m", maxRange: "1M" },
|
|
162
|
+
{ resolution: "1h", maxRange: "3M" },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
114
166
|
test("keeps the snapshot exchange when a newer quote omits it", async () => {
|
|
115
167
|
const historyExchanges: string[] = [];
|
|
116
168
|
const source = {
|
|
@@ -179,6 +231,11 @@ describe("resolveChartSpecData", () => {
|
|
|
179
231
|
expect(historyExchanges).toEqual(["XNAS"]);
|
|
180
232
|
expect(result.series[0]?.points.at(-1)?.date.toISOString())
|
|
181
233
|
.toBe("2026-07-30T15:26:00.000Z");
|
|
234
|
+
expect(result.series[0]?.timeBasis).toMatchObject({
|
|
235
|
+
kind: "market",
|
|
236
|
+
timeZone: "America/New_York",
|
|
237
|
+
cadenceMs: 5 * 60_000,
|
|
238
|
+
});
|
|
182
239
|
});
|
|
183
240
|
|
|
184
241
|
test("resolves the exchange before selecting an adaptive Auto resolution", async () => {
|
|
@@ -401,6 +458,87 @@ describe("resolveChartSpecData", () => {
|
|
|
401
458
|
});
|
|
402
459
|
});
|
|
403
460
|
|
|
461
|
+
test("accumulates historical windows without replacing the current tail", async () => {
|
|
462
|
+
const detailedRequests: Array<{ start: string; end: string }> = [];
|
|
463
|
+
const currentHistory = [
|
|
464
|
+
{ date: "2026-01-02T16:00:00.000Z" as unknown as Date, close: 110 },
|
|
465
|
+
{ date: new Date("2026-07-30T16:00:00.000Z"), close: 120 },
|
|
466
|
+
];
|
|
467
|
+
const historicalHistory = [
|
|
468
|
+
{ date: new Date("2024-08-01T16:00:00.000Z"), close: 90 },
|
|
469
|
+
{ date: new Date("2025-07-29T16:00:00.000Z"), close: 100 },
|
|
470
|
+
];
|
|
471
|
+
const provider = createTestDataProvider({
|
|
472
|
+
getTickerFinancials: async () => emptyFinancials(),
|
|
473
|
+
getChartResolutionSupport: () => [
|
|
474
|
+
{ resolution: "1h", maxRange: "1Y" },
|
|
475
|
+
{ resolution: "1d", maxRange: "ALL" },
|
|
476
|
+
],
|
|
477
|
+
getPriceHistoryForResolution: async () => currentHistory,
|
|
478
|
+
getDetailedPriceHistory: async (_symbol, _exchange, start, end) => {
|
|
479
|
+
detailedRequests.push({
|
|
480
|
+
start: start.toISOString(),
|
|
481
|
+
end: end.toISOString(),
|
|
482
|
+
});
|
|
483
|
+
return historicalHistory;
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
const spec: ChartSpec = {
|
|
487
|
+
version: CHART_SPEC_VERSION,
|
|
488
|
+
viewport: { range: "1Y", resolution: "1h" },
|
|
489
|
+
panels: [{ id: "main" }],
|
|
490
|
+
series: [{
|
|
491
|
+
id: "price",
|
|
492
|
+
source: {
|
|
493
|
+
kind: "security",
|
|
494
|
+
instrument: { symbol: "TEST", exchange: "NASDAQ" },
|
|
495
|
+
fieldId: "market.close",
|
|
496
|
+
},
|
|
497
|
+
style: "line",
|
|
498
|
+
transform: "raw",
|
|
499
|
+
axis: "left",
|
|
500
|
+
panelId: "main",
|
|
501
|
+
interpolation: "none",
|
|
502
|
+
}],
|
|
503
|
+
studies: [],
|
|
504
|
+
};
|
|
505
|
+
const sources = {
|
|
506
|
+
dataProvider: provider,
|
|
507
|
+
now: new Date("2026-07-30T16:00:00.000Z"),
|
|
508
|
+
loadFredSeries: async () => fredLoad(),
|
|
509
|
+
};
|
|
510
|
+
const cache = new ChartResolveCache();
|
|
511
|
+
|
|
512
|
+
const current = await resolveChartSpecData(spec, sources, cache);
|
|
513
|
+
const historical = await resolveChartSpecData(spec, sources, cache, {
|
|
514
|
+
requestViewport: {
|
|
515
|
+
start: new Date("2024-07-30T16:00:00.000Z"),
|
|
516
|
+
end: new Date("2025-07-30T16:00:00.000Z"),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
const revisitedCurrent = await resolveChartSpecData(spec, sources, cache);
|
|
520
|
+
const bufferedDates = (result: typeof current) => (
|
|
521
|
+
result.bufferedSeries?.[0]?.points.map((point) => point.date.toISOString())
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
expect(detailedRequests).toEqual([{
|
|
525
|
+
start: "2024-07-30T16:00:00.000Z",
|
|
526
|
+
end: "2025-07-30T16:00:00.001Z",
|
|
527
|
+
}]);
|
|
528
|
+
expect(current.resolutionSupport).toEqual([
|
|
529
|
+
{ resolution: "1h", maxRange: "1Y" },
|
|
530
|
+
{ resolution: "1d", maxRange: "ALL" },
|
|
531
|
+
]);
|
|
532
|
+
expect(bufferedDates(historical)).toEqual([
|
|
533
|
+
"2024-08-01T16:00:00.000Z",
|
|
534
|
+
"2025-07-29T16:00:00.000Z",
|
|
535
|
+
"2026-01-02T16:00:00.000Z",
|
|
536
|
+
"2026-07-30T16:00:00.000Z",
|
|
537
|
+
]);
|
|
538
|
+
expect(bufferedDates(revisitedCurrent)).toEqual(bufferedDates(historical));
|
|
539
|
+
expect(revisitedCurrent.bufferedSeries?.[0]?.points.at(-1)?.value).toBe(120);
|
|
540
|
+
});
|
|
541
|
+
|
|
404
542
|
test("keeps percent transforms and studies defined in a panned history window", async () => {
|
|
405
543
|
const provider = createTestDataProvider({
|
|
406
544
|
getTickerFinancials: async () => emptyFinancials(),
|
|
@@ -1029,6 +1167,74 @@ describe("resolveChartSpecData", () => {
|
|
|
1029
1167
|
expect(sma.points.at(-1)?.value).toBeCloseTo(15.5);
|
|
1030
1168
|
});
|
|
1031
1169
|
|
|
1170
|
+
test("keeps a quote received after resolution starts inside the latest viewport", async () => {
|
|
1171
|
+
const now = Date.parse("2026-08-05T14:00:00Z");
|
|
1172
|
+
const quoteTime = now + 60_000;
|
|
1173
|
+
const provider = createTestDataProvider({
|
|
1174
|
+
getTickerFinancials: async () => ({
|
|
1175
|
+
...emptyFinancials(),
|
|
1176
|
+
quote: {
|
|
1177
|
+
symbol: "FRESH",
|
|
1178
|
+
price: 130,
|
|
1179
|
+
currency: "USD",
|
|
1180
|
+
change: 30,
|
|
1181
|
+
changePercent: 30,
|
|
1182
|
+
lastUpdated: quoteTime,
|
|
1183
|
+
listingExchangeName: "XNAS",
|
|
1184
|
+
marketState: "POST",
|
|
1185
|
+
postMarketPrice: 129,
|
|
1186
|
+
postMarketChange: -1,
|
|
1187
|
+
postMarketChangePercent: -0.77,
|
|
1188
|
+
},
|
|
1189
|
+
}),
|
|
1190
|
+
getPriceHistoryForResolution: async () => [
|
|
1191
|
+
{ date: new Date(now - 2 * 86_400_000), open: 99, high: 102, low: 98, close: 100 },
|
|
1192
|
+
{ date: new Date(now - 86_400_000), open: 100, high: 101, low: 99, close: 100 },
|
|
1193
|
+
],
|
|
1194
|
+
});
|
|
1195
|
+
const spec: ChartSpec = {
|
|
1196
|
+
version: CHART_SPEC_VERSION,
|
|
1197
|
+
viewport: { range: "6M", resolution: "1d" },
|
|
1198
|
+
panels: [{ id: "main" }],
|
|
1199
|
+
series: [{
|
|
1200
|
+
id: "price",
|
|
1201
|
+
source: {
|
|
1202
|
+
kind: "security",
|
|
1203
|
+
instrument: { symbol: "FRESH", exchange: "XNAS" },
|
|
1204
|
+
fieldId: "market.ohlcv",
|
|
1205
|
+
},
|
|
1206
|
+
style: "candles",
|
|
1207
|
+
transform: "raw",
|
|
1208
|
+
axis: "left",
|
|
1209
|
+
panelId: "main",
|
|
1210
|
+
interpolation: "none",
|
|
1211
|
+
}],
|
|
1212
|
+
studies: [{
|
|
1213
|
+
id: "sma",
|
|
1214
|
+
kind: "sma",
|
|
1215
|
+
inputSeriesIds: ["price"],
|
|
1216
|
+
parameters: { period: 2 },
|
|
1217
|
+
panelId: "main",
|
|
1218
|
+
axis: "left",
|
|
1219
|
+
}],
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
const result = await resolveChartSpecData(spec, {
|
|
1223
|
+
dataProvider: provider,
|
|
1224
|
+
now: new Date(now),
|
|
1225
|
+
loadFredSeries: async () => fredLoad(),
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
expect(result.errors).toEqual([]);
|
|
1229
|
+
expect(result.viewport?.end.getTime()).toBe(quoteTime);
|
|
1230
|
+
const price = result.series.find((entry) => entry.id === "price");
|
|
1231
|
+
const sma = result.series.find((entry) => entry.id === "sma");
|
|
1232
|
+
expect(price?.points.at(-1)?.date.getTime()).toBe(quoteTime);
|
|
1233
|
+
expect(price?.points.at(-1)?.close).toBe(129);
|
|
1234
|
+
expect(price?.latestChangePercent).toBe(30);
|
|
1235
|
+
expect(sma?.points.at(-1)?.date.getTime()).toBe(quoteTime);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1032
1238
|
test("reuses raw source loads while live quotes recompute the chart tail", async () => {
|
|
1033
1239
|
const now = Date.parse("2026-05-15T20:30:00Z");
|
|
1034
1240
|
const source = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendLiveQuotePoint } from "./chart-data";
|
|
2
2
|
import {
|
|
3
3
|
getTimeRangeForDateWindow,
|
|
4
|
+
isDateWindowWithinTimeRange,
|
|
4
5
|
subtractTimeRange,
|
|
5
6
|
} from "./date-window";
|
|
6
7
|
import {
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
publicTickerKey,
|
|
46
47
|
resolveExchangeTimeZone,
|
|
47
48
|
} from "../utils/exchanges";
|
|
49
|
+
import { getPricePointTimestamp } from "../utils/price-history";
|
|
48
50
|
import type {
|
|
49
51
|
ChartResolutionResult,
|
|
50
52
|
ChartSeriesSpec,
|
|
@@ -87,6 +89,7 @@ export interface ChartResolveOptions {
|
|
|
87
89
|
export class ChartResolveCache {
|
|
88
90
|
readonly financialsByInstrument = new Map<string, Promise<TickerFinancials | null>>();
|
|
89
91
|
readonly priceHistoryByRequest = new Map<string, Promise<TickerFinancials["priceHistory"]>>();
|
|
92
|
+
readonly accumulatedPriceHistory = new Map<string, TickerFinancials["priceHistory"]>();
|
|
90
93
|
readonly resolutionSupportByInstrument = new Map<string, Promise<ChartResolutionSupport[]>>();
|
|
91
94
|
readonly fredSeriesByRequest = new Map<string, Promise<FredSeriesLoadResult>>();
|
|
92
95
|
}
|
|
@@ -99,6 +102,7 @@ interface DateBounds {
|
|
|
99
102
|
|
|
100
103
|
interface PriceHistoryRequest {
|
|
101
104
|
bounds: DateBounds;
|
|
105
|
+
visibleBounds: DateBounds;
|
|
102
106
|
explicitWindow: boolean;
|
|
103
107
|
fallbackRange: TimeRange;
|
|
104
108
|
resolution: ManualChartResolution;
|
|
@@ -124,6 +128,32 @@ function instrumentLabel(spec: Extract<ChartSeriesSpec["source"], { kind: "secur
|
|
|
124
128
|
return publicTickerKey(spec.instrument.symbol, spec.instrument.exchange);
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
// A series that fails to load keeps its place with no observations. Dropping it
|
|
132
|
+
// made a series the user had added disappear from the legend while it still sat
|
|
133
|
+
// in the series editor, with nothing on screen to explain the difference.
|
|
134
|
+
function unloadableSeries(spec: ChartSeriesSpec, index: number, warning: string): ResolvedSeries {
|
|
135
|
+
const field = spec.source.kind === "security" ? getTimeSeriesField(spec.source.fieldId) : undefined;
|
|
136
|
+
const label = spec.source.kind === "economic"
|
|
137
|
+
? `FRED ${spec.source.seriesId}`
|
|
138
|
+
: `${instrumentLabel(spec.source)} ${field?.shortLabel ?? spec.source.fieldId.split(".").at(-1) ?? "Series"}`;
|
|
139
|
+
return {
|
|
140
|
+
id: spec.id,
|
|
141
|
+
label: spec.label?.trim() || label,
|
|
142
|
+
color: spec.color ?? SERIES_COLORS[index % SERIES_COLORS.length]!,
|
|
143
|
+
unit: field?.unit ?? "",
|
|
144
|
+
unitGroup: field?.unitGroup ?? "unknown",
|
|
145
|
+
nativeFrequency: field?.nativeFrequency ?? "daily",
|
|
146
|
+
dataShape: field?.dataShape ?? "scalar",
|
|
147
|
+
style: spec.style,
|
|
148
|
+
transform: spec.transform,
|
|
149
|
+
axis: spec.axis === "right" ? "right" : "left",
|
|
150
|
+
panelId: spec.panelId,
|
|
151
|
+
interpolation: spec.interpolation,
|
|
152
|
+
warning,
|
|
153
|
+
points: [],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
function withQuoteExchange(
|
|
128
158
|
source: Extract<ChartSeriesSpec["source"], { kind: "security" }>,
|
|
129
159
|
...quotes: Array<Quote | undefined>
|
|
@@ -221,7 +251,21 @@ function requestResolution(
|
|
|
221
251
|
options: ChartResolveOptions,
|
|
222
252
|
sharedSupport: readonly ChartResolutionSupport[],
|
|
223
253
|
): ManualChartResolution {
|
|
224
|
-
if (spec.viewport.resolution !== "auto")
|
|
254
|
+
if (spec.viewport.resolution !== "auto") {
|
|
255
|
+
const maxRange = getSupportMaxRange(sharedSupport, spec.viewport.resolution);
|
|
256
|
+
const supported = sharedSupport.length === 0
|
|
257
|
+
|| (
|
|
258
|
+
maxRange !== null
|
|
259
|
+
&& bounds.start !== null
|
|
260
|
+
&& bounds.end !== null
|
|
261
|
+
&& isDateWindowWithinTimeRange(
|
|
262
|
+
new Date(bounds.start),
|
|
263
|
+
new Date(bounds.end),
|
|
264
|
+
maxRange,
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
if (supported) return spec.viewport.resolution;
|
|
268
|
+
}
|
|
225
269
|
const runtimeBounds = runtimeAutoBounds(options);
|
|
226
270
|
const adaptive = runtimeBounds && runtimeBounds.start !== null && runtimeBounds.end !== null
|
|
227
271
|
? getBestSupportedResolutionForVisibleWindow(
|
|
@@ -280,10 +324,73 @@ function filterPoints(points: readonly TimeSeriesPoint[], bounds: DateBounds): T
|
|
|
280
324
|
});
|
|
281
325
|
}
|
|
282
326
|
|
|
327
|
+
function followLatestMarketObservation(
|
|
328
|
+
bounds: DateBounds,
|
|
329
|
+
series: readonly ResolvedSeries[],
|
|
330
|
+
): DateBounds {
|
|
331
|
+
if (bounds.end === null) return bounds;
|
|
332
|
+
let latest = bounds.end;
|
|
333
|
+
for (const entry of series) {
|
|
334
|
+
if (entry.timeBasis?.kind !== "market") continue;
|
|
335
|
+
for (const point of entry.points) {
|
|
336
|
+
const timestamp = point.date.getTime();
|
|
337
|
+
if (Number.isFinite(timestamp) && timestamp > latest) latest = timestamp;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (latest === bounds.end) return bounds;
|
|
341
|
+
const shift = latest - bounds.end;
|
|
342
|
+
return {
|
|
343
|
+
start: bounds.start === null ? null : bounds.start + shift,
|
|
344
|
+
end: latest,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
283
348
|
function emptyFinancials(priceHistory: TickerFinancials["priceHistory"] = []): TickerFinancials {
|
|
284
349
|
return { annualStatements: [], quarterlyStatements: [], priceHistory };
|
|
285
350
|
}
|
|
286
351
|
|
|
352
|
+
function mergePriceHistoryWindows(
|
|
353
|
+
current: TickerFinancials["priceHistory"],
|
|
354
|
+
incoming: TickerFinancials["priceHistory"],
|
|
355
|
+
): TickerFinancials["priceHistory"] {
|
|
356
|
+
const byTimestamp = new Map<number, TickerFinancials["priceHistory"][number]>();
|
|
357
|
+
for (const point of [...current, ...incoming]) {
|
|
358
|
+
const timestamp = getPricePointTimestamp(point);
|
|
359
|
+
if (Number.isFinite(timestamp)) {
|
|
360
|
+
byTimestamp.set(
|
|
361
|
+
timestamp,
|
|
362
|
+
point.date instanceof Date ? point : { ...point, date: new Date(timestamp) },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return [...byTimestamp.values()].sort(
|
|
367
|
+
(left, right) => getPricePointTimestamp(left) - getPricePointTimestamp(right),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function historyIntersectsBounds(
|
|
372
|
+
history: TickerFinancials["priceHistory"],
|
|
373
|
+
bounds: DateBounds,
|
|
374
|
+
): boolean {
|
|
375
|
+
if (history.length === 0) return false;
|
|
376
|
+
if (bounds.start === null || bounds.end === null) return true;
|
|
377
|
+
return history.some((point) => {
|
|
378
|
+
const timestamp = getPricePointTimestamp(point);
|
|
379
|
+
return Number.isFinite(timestamp) && timestamp >= bounds.start! && timestamp <= bounds.end!;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function clampHistoryBoundsToSupport(
|
|
384
|
+
bounds: DateBounds,
|
|
385
|
+
maxRange: TimeRange | null,
|
|
386
|
+
): DateBounds {
|
|
387
|
+
if (bounds.start === null || bounds.end === null || !maxRange || maxRange === "ALL") {
|
|
388
|
+
return bounds;
|
|
389
|
+
}
|
|
390
|
+
const supportedStart = subtractTimeRange(new Date(bounds.end), maxRange).getTime();
|
|
391
|
+
return { start: Math.max(bounds.start, supportedStart), end: bounds.end };
|
|
392
|
+
}
|
|
393
|
+
|
|
287
394
|
function latestQuote(snapshot: Quote | undefined, override: Quote | undefined): Quote | undefined {
|
|
288
395
|
if (!snapshot) return override;
|
|
289
396
|
if (!override) return snapshot;
|
|
@@ -326,7 +433,7 @@ async function loadPriceHistory(
|
|
|
326
433
|
request.resolution,
|
|
327
434
|
context,
|
|
328
435
|
);
|
|
329
|
-
if (detailed.
|
|
436
|
+
if (historyIntersectsBounds(detailed, request.visibleBounds)) return detailed;
|
|
330
437
|
} catch {
|
|
331
438
|
// Fall through to trailing history when a provider cannot serve the exact window.
|
|
332
439
|
}
|
|
@@ -340,7 +447,12 @@ async function loadPriceHistory(
|
|
|
340
447
|
request.resolution,
|
|
341
448
|
context,
|
|
342
449
|
);
|
|
343
|
-
if (
|
|
450
|
+
if (
|
|
451
|
+
resolved.length > 0
|
|
452
|
+
&& (!request.explicitWindow || historyIntersectsBounds(resolved, request.visibleBounds))
|
|
453
|
+
) {
|
|
454
|
+
return resolved;
|
|
455
|
+
}
|
|
344
456
|
} catch {
|
|
345
457
|
// Some providers expose the resolution API but only support a subset.
|
|
346
458
|
}
|
|
@@ -352,12 +464,20 @@ async function loadPriceHistory(
|
|
|
352
464
|
`Requested ${request.resolution} price history is unavailable for ${instrumentLabel(source)}. Choose Auto or a supported interval.`,
|
|
353
465
|
);
|
|
354
466
|
}
|
|
355
|
-
|
|
467
|
+
const fallback = await provider.getPriceHistory(
|
|
356
468
|
source.instrument.symbol,
|
|
357
469
|
source.instrument.exchange ?? "",
|
|
358
470
|
request.fallbackRange,
|
|
359
471
|
context,
|
|
360
472
|
);
|
|
473
|
+
if (
|
|
474
|
+
request.explicitWindow
|
|
475
|
+
&& fallback.length > 0
|
|
476
|
+
&& !historyIntersectsBounds(fallback, request.visibleBounds)
|
|
477
|
+
) {
|
|
478
|
+
throw new Error(`Price history for the requested window is unavailable for ${instrumentLabel(source)}.`);
|
|
479
|
+
}
|
|
480
|
+
return fallback;
|
|
361
481
|
}
|
|
362
482
|
|
|
363
483
|
function baseSecuritySeries(
|
|
@@ -381,10 +501,14 @@ function baseSecuritySeries(
|
|
|
381
501
|
const marketExchange = spec.source.instrument.exchange
|
|
382
502
|
|| financials.quote?.listingExchangeName
|
|
383
503
|
|| financials.quote?.exchangeName;
|
|
384
|
-
const
|
|
504
|
+
const marketField = isMarketFieldId(spec.source.fieldId);
|
|
505
|
+
const marketTimeZone = marketField
|
|
385
506
|
&& canonicalExchange(marketExchange) !== "CCC"
|
|
386
507
|
? resolveExchangeTimeZone(marketExchange)
|
|
387
508
|
: null;
|
|
509
|
+
const latestChangePercent = marketField && field.unit.startsWith("currency")
|
|
510
|
+
? financials.quote?.changePercent
|
|
511
|
+
: undefined;
|
|
388
512
|
return {
|
|
389
513
|
id: spec.id,
|
|
390
514
|
label: spec.label?.trim() || `${symbol} ${field.shortLabel}`,
|
|
@@ -410,6 +534,9 @@ function baseSecuritySeries(
|
|
|
410
534
|
: undefined,
|
|
411
535
|
}
|
|
412
536
|
: undefined,
|
|
537
|
+
latestChangePercent: typeof latestChangePercent === "number" && Number.isFinite(latestChangePercent)
|
|
538
|
+
? latestChangePercent
|
|
539
|
+
: undefined,
|
|
413
540
|
points,
|
|
414
541
|
};
|
|
415
542
|
}
|
|
@@ -640,14 +767,12 @@ export async function resolveChartSpecData(
|
|
|
640
767
|
? [[instrumentKey(entry.source), entry.source] as const]
|
|
641
768
|
: []
|
|
642
769
|
))).values()];
|
|
643
|
-
const resolutionSupportSources =
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
: activeMarketSources;
|
|
650
|
-
const sharedSupport = adaptiveBounds && activeMarketSources.length > 0
|
|
770
|
+
const resolutionSupportSources = await Promise.all(activeMarketSources.map(async (source) => (
|
|
771
|
+
source.instrument.exchange?.trim()
|
|
772
|
+
? source
|
|
773
|
+
: sourceWithResolvedExchange(source, await loadFinancials(source))
|
|
774
|
+
)));
|
|
775
|
+
const sharedSupport = activeMarketSources.length > 0
|
|
651
776
|
? intersectChartResolutionSupport(await Promise.all(
|
|
652
777
|
resolutionSupportSources.map((source) => loadResolutionSupport(source)),
|
|
653
778
|
))
|
|
@@ -659,6 +784,14 @@ export async function resolveChartSpecData(
|
|
|
659
784
|
options,
|
|
660
785
|
sharedSupport,
|
|
661
786
|
);
|
|
787
|
+
if (
|
|
788
|
+
spec.viewport.resolution !== "auto"
|
|
789
|
+
&& initialResolution !== spec.viewport.resolution
|
|
790
|
+
) {
|
|
791
|
+
warnings.push(
|
|
792
|
+
`${spec.viewport.resolution.toUpperCase()} data is unavailable for this range. Auto resolution was used instead.`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
662
795
|
const requestVisibleBounds = requestBounds ?? initialVisibleBounds;
|
|
663
796
|
const initialCalculationBounds = calculationBounds(
|
|
664
797
|
spec,
|
|
@@ -672,16 +805,18 @@ export async function resolveChartSpecData(
|
|
|
672
805
|
source: Extract<ChartSeriesSpec["source"], { kind: "security" }>,
|
|
673
806
|
all = false,
|
|
674
807
|
) => {
|
|
675
|
-
const requestedFallbackRange = all
|
|
676
|
-
? "ALL"
|
|
677
|
-
: trailingRangeForStart(initialCalculationBounds.start, referenceNow);
|
|
678
808
|
const support = await loadResolutionSupport(source);
|
|
679
809
|
const maxRange = getSupportMaxRange(support, initialResolution);
|
|
810
|
+
const historyBounds = clampHistoryBoundsToSupport(initialCalculationBounds, maxRange);
|
|
811
|
+
const requestedFallbackRange = all
|
|
812
|
+
? "ALL"
|
|
813
|
+
: trailingRangeForStart(historyBounds.start, referenceNow);
|
|
680
814
|
const fallbackRange = maxRange
|
|
681
815
|
? clampTimeRangeToMaxRange(requestedFallbackRange, maxRange)
|
|
682
816
|
: requestedFallbackRange;
|
|
683
817
|
const request: PriceHistoryRequest = {
|
|
684
|
-
bounds:
|
|
818
|
+
bounds: historyBounds,
|
|
819
|
+
visibleBounds: requestVisibleBounds,
|
|
685
820
|
explicitWindow: hasExplicitWindow,
|
|
686
821
|
fallbackRange,
|
|
687
822
|
resolution: initialResolution,
|
|
@@ -700,7 +835,24 @@ export async function resolveChartSpecData(
|
|
|
700
835
|
pending = loadPriceHistory(sources.dataProvider!, source, request);
|
|
701
836
|
cache.priceHistoryByRequest.set(key, pending);
|
|
702
837
|
}
|
|
703
|
-
|
|
838
|
+
const history = await pending;
|
|
839
|
+
const accumulationKey = `${instrumentKey(source)}|${request.resolution}`;
|
|
840
|
+
const previousHistory = cache.accumulatedPriceHistory.get(accumulationKey) ?? [];
|
|
841
|
+
if (
|
|
842
|
+
request.explicitWindow
|
|
843
|
+
&& !historyIntersectsBounds(history, request.visibleBounds)
|
|
844
|
+
&& !historyIntersectsBounds(previousHistory, request.visibleBounds)
|
|
845
|
+
) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
`Price history for the requested window is unavailable for ${instrumentLabel(source)}.`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const accumulated = mergePriceHistoryWindows(
|
|
851
|
+
previousHistory,
|
|
852
|
+
history,
|
|
853
|
+
);
|
|
854
|
+
cache.accumulatedPriceHistory.set(accumulationKey, accumulated);
|
|
855
|
+
return accumulated;
|
|
704
856
|
};
|
|
705
857
|
|
|
706
858
|
const loadEconomicSeries = (request: FredSeriesRequest) => {
|
|
@@ -739,17 +891,19 @@ export async function resolveChartSpecData(
|
|
|
739
891
|
? sources.quoteOverrides?.get(chartQuoteOverrideKeyForSource(source))
|
|
740
892
|
: undefined;
|
|
741
893
|
const financialsPromise = loadFinancials(source);
|
|
894
|
+
let resolvedSource = source;
|
|
742
895
|
let financials: TickerFinancials | null;
|
|
743
896
|
let history: TickerFinancials["priceHistory"] | null;
|
|
744
897
|
if (needsHistory && !source.instrument.exchange?.trim()) {
|
|
745
898
|
financials = await financialsPromise;
|
|
746
|
-
|
|
747
|
-
history = await loadHistory(
|
|
899
|
+
resolvedSource = sourceWithResolvedExchange(source, financials);
|
|
900
|
+
history = await loadHistory(resolvedSource, quoteDerivedValuation);
|
|
748
901
|
} else {
|
|
749
902
|
[financials, history] = await Promise.all([
|
|
750
903
|
financialsPromise,
|
|
751
904
|
needsHistory ? loadHistory(source, quoteDerivedValuation) : Promise.resolve(null),
|
|
752
905
|
]);
|
|
906
|
+
resolvedSource = sourceWithResolvedExchange(source, financials);
|
|
753
907
|
}
|
|
754
908
|
const liveBarResolution = isOhlcSeriesStyle(seriesSpec.style) ? initialResolution : undefined;
|
|
755
909
|
const merged = history
|
|
@@ -758,15 +912,21 @@ export async function resolveChartSpecData(
|
|
|
758
912
|
? { ...financials, quote: latestQuote(financials.quote, quoteOverride) }
|
|
759
913
|
: financials;
|
|
760
914
|
if (!merged) throw new Error(`No financial data is available for ${instrumentLabel(source)}.`);
|
|
761
|
-
const result = baseSecuritySeries(
|
|
915
|
+
const result = baseSecuritySeries(
|
|
916
|
+
resolvedSource === source ? seriesSpec : { ...seriesSpec, source: resolvedSource },
|
|
917
|
+
merged,
|
|
918
|
+
index,
|
|
919
|
+
initialResolution,
|
|
920
|
+
);
|
|
762
921
|
if (!result) throw new Error(`Unknown field ${source.fieldId}.`);
|
|
763
922
|
if (isFundamentalFieldId(source.fieldId) && fundamentalSeriesUsesAvailabilityFallback(merged, source)) {
|
|
764
923
|
result.warning = "Publication dates are unavailable for some observations; period-end dates are used as a fallback.";
|
|
765
924
|
}
|
|
766
925
|
return result;
|
|
767
926
|
} catch (error) {
|
|
768
|
-
|
|
769
|
-
|
|
927
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
928
|
+
errors.push(`${seriesSpec.label ?? seriesSpec.id}: ${message}`);
|
|
929
|
+
return unloadableSeries(seriesSpec, index, message);
|
|
770
930
|
}
|
|
771
931
|
}));
|
|
772
932
|
|
|
@@ -774,13 +934,19 @@ export async function resolveChartSpecData(
|
|
|
774
934
|
const marketTimelineSeries = primaryMarketSeries?.visible === false
|
|
775
935
|
? rawSeries.filter((entry) => entry.id === primaryMarketSeries.id)
|
|
776
936
|
: [];
|
|
777
|
-
// Preset ranges
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
const bounds =
|
|
937
|
+
// Preset ranges normally end at the requested reference time. A quote fetched
|
|
938
|
+
// asynchronously can be timestamped just after that reference, so advance an
|
|
939
|
+
// untouched market viewport by the same amount instead of clipping its tail.
|
|
940
|
+
// Explicit and user-created windows stay fixed through hasExplicitWindow.
|
|
941
|
+
const bounds = hasExplicitWindow
|
|
942
|
+
? initialVisibleBounds
|
|
943
|
+
: followLatestMarketObservation(initialVisibleBounds, rawSeries);
|
|
782
944
|
const resolution = initialResolution;
|
|
783
|
-
const studyBounds =
|
|
945
|
+
const studyBounds = bounds.end !== null
|
|
946
|
+
&& initialCalculationBounds.end !== null
|
|
947
|
+
&& bounds.end > initialCalculationBounds.end
|
|
948
|
+
? { ...initialCalculationBounds, end: bounds.end }
|
|
949
|
+
: initialCalculationBounds;
|
|
784
950
|
const baseSeries = rawSeries
|
|
785
951
|
.filter((entry) => visibleSeriesIds.has(entry.id))
|
|
786
952
|
.map((entry) => prepareBaseSeriesForStudies(entry, bounds, false, requestVisibleBounds));
|
|
@@ -850,6 +1016,7 @@ export async function resolveChartSpecData(
|
|
|
850
1016
|
: undefined;
|
|
851
1017
|
return {
|
|
852
1018
|
series: resolved,
|
|
1019
|
+
...(sharedSupport.length > 0 ? { resolutionSupport: sharedSupport } : {}),
|
|
853
1020
|
legendSeries,
|
|
854
1021
|
...(spec.viewport.maxPoints === undefined ? { bufferedSeries } : {}),
|
|
855
1022
|
...(marketTimelineSeries.length > 0 ? { timelineSeries: marketTimelineSeries } : {}),
|
|
@@ -168,6 +168,10 @@ describe("chart spec normalization and validation", () => {
|
|
|
168
168
|
expect(result.valid).toBe(false);
|
|
169
169
|
expect(result.errors.map(({ code }) => code)).toContain("qoq-annual");
|
|
170
170
|
expect(result.errors.map(({ code }) => code)).toContain("multiple-ohlc");
|
|
171
|
+
// The blocking series is usually hidden or drawn as a line for lack of OHLC
|
|
172
|
+
// data, so the message has to name it or it reads as a phantom conflict.
|
|
173
|
+
expect(result.errors.find(({ code }) => code === "multiple-ohlc")?.message)
|
|
174
|
+
.toBe("A already uses a candle or OHLC style on main. Give it another style first.");
|
|
171
175
|
expect(result.errors.map(({ code }) => code)).toContain("missing-input");
|
|
172
176
|
});
|
|
173
177
|
|
package/src/time-series/spec.ts
CHANGED
|
@@ -359,6 +359,7 @@ export function validateChartSpec(spec: ChartSpec): ChartSpecValidationResult {
|
|
|
359
359
|
|
|
360
360
|
const seriesIds = new Set<string>();
|
|
361
361
|
const candleCountByPanel = new Map<string, number>();
|
|
362
|
+
const firstCandleByPanel = new Map<string, string>();
|
|
362
363
|
const unitGroupsByPanel = new Map<string, Set<string>>();
|
|
363
364
|
const panelScaleById = new Map(spec.panels.map((panel) => [panel.id, panel.scale ?? "linear"] as const));
|
|
364
365
|
spec.series.forEach((entry, index) => {
|
|
@@ -435,6 +436,13 @@ export function validateChartSpec(spec: ChartSpec): ChartSpecValidationResult {
|
|
|
435
436
|
if (isOhlcSeriesStyle(entry.style)) {
|
|
436
437
|
const count = (candleCountByPanel.get(entry.panelId) ?? 0) + 1;
|
|
437
438
|
candleCountByPanel.set(entry.panelId, count);
|
|
439
|
+
if (count === 1) {
|
|
440
|
+
firstCandleByPanel.set(
|
|
441
|
+
entry.panelId,
|
|
442
|
+
entry.label?.trim()
|
|
443
|
+
|| (entry.source.kind === "economic" ? entry.source.seriesId : entry.source.instrument.symbol),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
438
446
|
if (entry.transform !== "raw") {
|
|
439
447
|
errors.push(issue(`${path}.transform`, "transformed-ohlc", "OHLC styles require raw values."));
|
|
440
448
|
}
|
|
@@ -442,7 +450,13 @@ export function validateChartSpec(spec: ChartSpec): ChartSpecValidationResult {
|
|
|
442
450
|
});
|
|
443
451
|
for (const [panelId, count] of candleCountByPanel) {
|
|
444
452
|
if (count > 1) {
|
|
445
|
-
|
|
453
|
+
// Name the series holding the slot: it is often hidden or drawn as a line
|
|
454
|
+
// for lack of OHLC data, so "this panel already has one" reads as a ghost.
|
|
455
|
+
const panelLabel = spec.panels.find((panel) => panel.id === panelId)?.label ?? panelId;
|
|
456
|
+
const holder = firstCandleByPanel.get(panelId);
|
|
457
|
+
errors.push(issue("series", "multiple-ohlc", holder
|
|
458
|
+
? `${holder} already uses a candle or OHLC style on ${panelLabel}. Give it another style first.`
|
|
459
|
+
: `Panel ${panelLabel} may contain only one candle or OHLC series.`));
|
|
446
460
|
}
|
|
447
461
|
}
|
|
448
462
|
for (const [panelId, groups] of unitGroupsByPanel) {
|