gloomberb 0.15.7 → 0.15.8
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 +1 -0
- package/package.json +1 -1
- package/src/api-client/data.ts +5 -0
- package/src/api-client/doe.ts +75 -0
- package/src/api-client/index.ts +1 -0
- package/src/api-client/paths.ts +3 -0
- package/src/api-client/types.ts +2 -0
- package/src/cli/pane-functions/function-help.ts +15 -0
- package/src/components/chart/composite/composite-chart.tsx +15 -2
- package/src/components/chart/composite/format.ts +24 -11
- package/src/components/chart/composite/rasterizer.ts +67 -16
- package/src/components/chart/composite/scene.ts +53 -2
- package/src/components/chart/composite/session-shading.ts +3 -2
- package/src/components/chart/composite/text-renderer.ts +52 -1
- package/src/components/chart/composite/time-axis.ts +100 -30
- package/src/components/chart/composite/tools.ts +2 -1
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/command-bar/workflow/collection-actions.ts +9 -7
- package/src/components/layout/shell/index.tsx +38 -28
- package/src/components/onboarding/portfolio-step/positions-panel.tsx +14 -5
- package/src/components/onboarding/wizard-model.ts +13 -5
- package/src/components/onboarding/wizard-positions.ts +56 -7
- package/src/components/ui/display.tsx +6 -3
- package/src/components/ui/fields.tsx +5 -0
- package/src/components/ui/header-case.ts +25 -3
- package/src/components/ui/query-bar.tsx +8 -1
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/market-data/market/trading-sessions.ts +65 -1
- package/src/plugins/builtin/chart-composer/cli-options.ts +4 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +6 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +28 -1
- package/src/plugins/builtin/composite-plugins.ts +2 -0
- package/src/plugins/builtin/doe/client.ts +73 -0
- package/src/plugins/builtin/doe/headless.ts +93 -0
- package/src/plugins/builtin/doe/index.tsx +41 -0
- package/src/plugins/builtin/doe/model.ts +215 -0
- package/src/plugins/builtin/doe/pane.tsx +148 -0
- package/src/plugins/builtin/doe/range-bar.tsx +45 -0
- package/src/plugins/builtin/earnings-calls/transcript-view.tsx +7 -7
- package/src/plugins/builtin/executives/pane.tsx +4 -4
- package/src/plugins/builtin/fear-greed/charts.tsx +1 -1
- package/src/plugins/builtin/holders/pane.tsx +1 -1
- package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
- package/src/plugins/builtin/options/view.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/cli/render.ts +1 -1
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -20
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +53 -1
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +6 -6
- package/src/plugins/builtin/risk-factors/pane.tsx +4 -4
- package/src/plugins/builtin/shared/ticker-request.ts +1 -1
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +42 -10
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +1 -1
- package/src/plugins/catalog-browser.ts +2 -0
- package/src/sources/gloomberb-cloud/index.ts +47 -13
- package/src/sources/history-result.ts +1 -0
- package/src/sources/history-retention.ts +16 -0
- package/src/sources/provider-router/history.ts +4 -2
- package/src/sync/core-contributors.ts +18 -2
- package/src/tickers/open-target.ts +1 -1
- package/src/tickers/search/index.ts +44 -11
- package/src/tickers/search/upsert.ts +4 -1
- package/src/time-series/live-bars.ts +8 -3
- package/src/time-series/resolve.ts +224 -17
- package/src/time-series/spec.ts +1 -0
- package/src/time-series/types.ts +9 -1
- package/src/types/data-provider.ts +2 -0
- package/src/types/price-history.ts +2 -0
- package/src/utils/exchanges.ts +1 -0
- package/src/utils/price-history.ts +35 -1
- package/src/utils/sec.ts +2 -1
- package/src/utils/zoned-date-time.ts +67 -0
|
@@ -2,6 +2,7 @@ import type { CompositeViewportRange } from "./interactions";
|
|
|
2
2
|
import { projectCompositeTimestamp } from "./time-scale";
|
|
3
3
|
import type { CompositeChartScene } from "./types";
|
|
4
4
|
import { clamp } from "../../../utils/math";
|
|
5
|
+
import { timeZoneLabel, zonedWallClockMs, zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
|
|
5
6
|
|
|
6
7
|
const MONTHS = [
|
|
7
8
|
"Jan",
|
|
@@ -63,6 +64,43 @@ export interface CompositeTimeAxisLayout {
|
|
|
63
64
|
ticks: CompositeTimeAxisTick[];
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The clock the axis reads: wall-clock readings are timestamps whose UTC
|
|
69
|
+
* fields show the local date and time, so calendar steps and labels work the
|
|
70
|
+
* same in any zone.
|
|
71
|
+
*/
|
|
72
|
+
interface AxisClock {
|
|
73
|
+
wall(timestamp: number): number;
|
|
74
|
+
instant(wall: number): number;
|
|
75
|
+
label: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const UTC_CLOCK: AxisClock = { wall: (timestamp) => timestamp, instant: (wall) => wall, label: "UTC" };
|
|
79
|
+
const axisClocks = new Map<string, AxisClock>();
|
|
80
|
+
|
|
81
|
+
function axisClock(timeZone: string | undefined): AxisClock {
|
|
82
|
+
if (!timeZone || timeZone === "UTC") return UTC_CLOCK;
|
|
83
|
+
let clock = axisClocks.get(timeZone);
|
|
84
|
+
if (!clock) {
|
|
85
|
+
try {
|
|
86
|
+
zonedWallClockMs(0, timeZone);
|
|
87
|
+
} catch {
|
|
88
|
+
return UTC_CLOCK;
|
|
89
|
+
}
|
|
90
|
+
clock = {
|
|
91
|
+
wall: (timestamp) => zonedWallClockMs(timestamp, timeZone),
|
|
92
|
+
instant: (wall) => {
|
|
93
|
+
const date = new Date(wall);
|
|
94
|
+
return zonedWallClockToUtcMs(timeZone, date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(),
|
|
95
|
+
date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds());
|
|
96
|
+
},
|
|
97
|
+
label: timeZoneLabel(timeZone),
|
|
98
|
+
};
|
|
99
|
+
axisClocks.set(timeZone, clock);
|
|
100
|
+
}
|
|
101
|
+
return clock;
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
const TIME_AXIS_INTERVALS: readonly CompositeTimeAxisInterval[] = [
|
|
67
105
|
{ unit: "millisecond", step: 1, approximateMs: 1 },
|
|
68
106
|
{ unit: "millisecond", step: 5, approximateMs: 5 },
|
|
@@ -104,9 +142,9 @@ function validTimestamp(value: number): boolean {
|
|
|
104
142
|
return Number.isFinite(value);
|
|
105
143
|
}
|
|
106
144
|
|
|
107
|
-
function axisLabelWidth(interval: CompositeTimeAxisInterval, startTime: number, endTime: number): number {
|
|
108
|
-
const start = new Date(startTime);
|
|
109
|
-
const end = new Date(endTime);
|
|
145
|
+
function axisLabelWidth(interval: CompositeTimeAxisInterval, startTime: number, endTime: number, clock: AxisClock): number {
|
|
146
|
+
const start = new Date(clock.wall(startTime));
|
|
147
|
+
const end = new Date(clock.wall(endTime));
|
|
110
148
|
const spansDays = start.getUTCFullYear() !== end.getUTCFullYear()
|
|
111
149
|
|| start.getUTCMonth() !== end.getUTCMonth()
|
|
112
150
|
|| start.getUTCDate() !== end.getUTCDate();
|
|
@@ -134,10 +172,11 @@ function resolveTimeAxisInterval(
|
|
|
134
172
|
startTime: number,
|
|
135
173
|
endTime: number,
|
|
136
174
|
width: number,
|
|
175
|
+
clock: AxisClock,
|
|
137
176
|
): CompositeTimeAxisInterval {
|
|
138
177
|
const span = Math.max(endTime - startTime, 1);
|
|
139
178
|
for (const interval of TIME_AXIS_INTERVALS) {
|
|
140
|
-
const labelWidth = axisLabelWidth(interval, startTime, endTime);
|
|
179
|
+
const labelWidth = axisLabelWidth(interval, startTime, endTime, clock);
|
|
141
180
|
const maximumLabels = Math.max(2, Math.floor(width / (labelWidth + 2)));
|
|
142
181
|
const estimatedLabels = Math.floor(span / interval.approximateMs) + 1;
|
|
143
182
|
if (estimatedLabels <= maximumLabels) return interval;
|
|
@@ -185,10 +224,28 @@ function nextYearBoundary(startTime: number, step: number): number {
|
|
|
185
224
|
return boundary;
|
|
186
225
|
}
|
|
187
226
|
|
|
227
|
+
/** Calendar steps between two instants, taken on the axis clock. */
|
|
188
228
|
function calendarBoundaries(
|
|
189
229
|
startTime: number,
|
|
190
230
|
endTime: number,
|
|
191
231
|
interval: CompositeTimeAxisInterval,
|
|
232
|
+
clock: AxisClock,
|
|
233
|
+
): number[] {
|
|
234
|
+
const instants: number[] = [];
|
|
235
|
+
for (const wall of wallBoundaries(clock.wall(startTime), clock.wall(endTime), interval)) {
|
|
236
|
+
const instant = clock.instant(wall);
|
|
237
|
+
// A wall time skipped or repeated by a clock change lands on a neighbour.
|
|
238
|
+
if (instant > startTime && instant < endTime && instant > (instants.at(-1) ?? Number.NEGATIVE_INFINITY)) {
|
|
239
|
+
instants.push(instant);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return instants;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function wallBoundaries(
|
|
246
|
+
startTime: number,
|
|
247
|
+
endTime: number,
|
|
248
|
+
interval: CompositeTimeAxisInterval,
|
|
192
249
|
): number[] {
|
|
193
250
|
let boundary: number;
|
|
194
251
|
let advance: (timestamp: number) => number;
|
|
@@ -252,9 +309,11 @@ function calendarBoundaries(
|
|
|
252
309
|
}
|
|
253
310
|
|
|
254
311
|
function intervalBucketKey(
|
|
255
|
-
|
|
312
|
+
instant: number,
|
|
256
313
|
interval: CompositeTimeAxisInterval,
|
|
314
|
+
clock: AxisClock,
|
|
257
315
|
): number {
|
|
316
|
+
const timestamp = clock.wall(instant);
|
|
258
317
|
switch (interval.unit) {
|
|
259
318
|
case "millisecond":
|
|
260
319
|
return Math.floor(timestamp / interval.step);
|
|
@@ -305,7 +364,7 @@ function normalizeMarketSamples(scene: CompositeChartScene): CompositeTimeAxisSa
|
|
|
305
364
|
));
|
|
306
365
|
}
|
|
307
366
|
|
|
308
|
-
function
|
|
367
|
+
function sameWallDay(left: number, right: number): boolean {
|
|
309
368
|
const leftDate = new Date(left);
|
|
310
369
|
const rightDate = new Date(right);
|
|
311
370
|
return leftDate.getUTCFullYear() === rightDate.getUTCFullYear()
|
|
@@ -313,7 +372,7 @@ function sameUtcDay(left: number, right: number): boolean {
|
|
|
313
372
|
&& leftDate.getUTCDate() === rightDate.getUTCDate();
|
|
314
373
|
}
|
|
315
374
|
|
|
316
|
-
function
|
|
375
|
+
function sameWallMonth(left: number, right: number): boolean {
|
|
317
376
|
const leftDate = new Date(left);
|
|
318
377
|
const rightDate = new Date(right);
|
|
319
378
|
return leftDate.getUTCFullYear() === rightDate.getUTCFullYear()
|
|
@@ -338,12 +397,15 @@ function calendarLabel(timestamp: number, includeYear: boolean): string {
|
|
|
338
397
|
}
|
|
339
398
|
|
|
340
399
|
function formatBoundaryLabel(
|
|
341
|
-
|
|
342
|
-
|
|
400
|
+
instant: number,
|
|
401
|
+
counterpartInstant: number,
|
|
343
402
|
interval: CompositeTimeAxisInterval,
|
|
403
|
+
axis: AxisClock,
|
|
344
404
|
includeTimeZone = true,
|
|
345
405
|
includeBoundaryYear = true,
|
|
346
406
|
): string {
|
|
407
|
+
const timestamp = axis.wall(instant);
|
|
408
|
+
const counterpart = axis.wall(counterpartInstant);
|
|
347
409
|
const date = new Date(timestamp);
|
|
348
410
|
switch (interval.unit) {
|
|
349
411
|
case "millisecond":
|
|
@@ -351,8 +413,8 @@ function formatBoundaryLabel(
|
|
|
351
413
|
case "minute":
|
|
352
414
|
case "hour": {
|
|
353
415
|
const clock = clockLabel(timestamp, interval.unit);
|
|
354
|
-
const suffix = includeTimeZone ?
|
|
355
|
-
if (
|
|
416
|
+
const suffix = includeTimeZone ? ` ${axis.label}` : "";
|
|
417
|
+
if (sameWallDay(timestamp, counterpart)) return `${clock}${suffix}`;
|
|
356
418
|
const includeYear = includeBoundaryYear
|
|
357
419
|
&& date.getUTCFullYear() !== new Date(counterpart).getUTCFullYear();
|
|
358
420
|
return `${calendarLabel(timestamp, includeYear)} ${clock}${suffix}`;
|
|
@@ -368,10 +430,13 @@ function formatBoundaryLabel(
|
|
|
368
430
|
}
|
|
369
431
|
|
|
370
432
|
function formatInteriorLabel(
|
|
371
|
-
|
|
372
|
-
|
|
433
|
+
instant: number,
|
|
434
|
+
previousInstant: number,
|
|
373
435
|
interval: CompositeTimeAxisInterval,
|
|
436
|
+
axis: AxisClock,
|
|
374
437
|
): string {
|
|
438
|
+
const timestamp = axis.wall(instant);
|
|
439
|
+
const previousTimestamp = axis.wall(previousInstant);
|
|
375
440
|
const date = new Date(timestamp);
|
|
376
441
|
const previous = new Date(previousTimestamp);
|
|
377
442
|
switch (interval.unit) {
|
|
@@ -380,7 +445,7 @@ function formatInteriorLabel(
|
|
|
380
445
|
case "minute":
|
|
381
446
|
case "hour": {
|
|
382
447
|
const clock = clockLabel(timestamp, interval.unit);
|
|
383
|
-
if (
|
|
448
|
+
if (sameWallDay(timestamp, previousTimestamp)) return clock;
|
|
384
449
|
return `${calendarLabel(
|
|
385
450
|
timestamp,
|
|
386
451
|
date.getUTCFullYear() !== previous.getUTCFullYear(),
|
|
@@ -388,7 +453,7 @@ function formatInteriorLabel(
|
|
|
388
453
|
}
|
|
389
454
|
case "day":
|
|
390
455
|
case "week":
|
|
391
|
-
if (
|
|
456
|
+
if (sameWallMonth(timestamp, previousTimestamp)) return `${date.getUTCDate()}`;
|
|
392
457
|
return calendarLabel(
|
|
393
458
|
timestamp,
|
|
394
459
|
date.getUTCFullYear() !== previous.getUTCFullYear(),
|
|
@@ -433,10 +498,13 @@ function layoutTimeAxis({
|
|
|
433
498
|
width,
|
|
434
499
|
samples,
|
|
435
500
|
timeRatio,
|
|
501
|
+
timeZone,
|
|
436
502
|
}: {
|
|
437
503
|
startTime: number;
|
|
438
504
|
endTime: number;
|
|
439
505
|
width: number;
|
|
506
|
+
/** IANA zone the labels read in; UTC when absent. */
|
|
507
|
+
timeZone?: string;
|
|
440
508
|
samples?: readonly CompositeTimeAxisSample[];
|
|
441
509
|
/**
|
|
442
510
|
* Where a timestamp lands across the plot. Defaults to the viewport filling
|
|
@@ -451,7 +519,8 @@ function layoutTimeAxis({
|
|
|
451
519
|
return { text: axis.join(""), ticks: [] };
|
|
452
520
|
}
|
|
453
521
|
|
|
454
|
-
const
|
|
522
|
+
const clock = axisClock(timeZone);
|
|
523
|
+
const interval = resolveTimeAxisInterval(startTime, endTime, axisWidth, clock);
|
|
455
524
|
const span = Math.max(endTime - startTime, 1);
|
|
456
525
|
const ratioAt = (timestamp: number) => {
|
|
457
526
|
const projected = timeRatio?.(timestamp);
|
|
@@ -469,14 +538,14 @@ function layoutTimeAxis({
|
|
|
469
538
|
ratio: 0,
|
|
470
539
|
boundary: true,
|
|
471
540
|
}];
|
|
472
|
-
const firstBucket = intervalBucketKey(firstTimestamp, interval);
|
|
541
|
+
const firstBucket = intervalBucketKey(firstTimestamp, interval, clock);
|
|
473
542
|
|
|
474
|
-
for (const boundary of calendarBoundaries(startTime, endTime, interval)) {
|
|
543
|
+
for (const boundary of calendarBoundaries(startTime, endTime, interval, clock)) {
|
|
475
544
|
if (marketSamples) {
|
|
476
545
|
const sample = marketSamples[lowerBoundSample(marketSamples, boundary)];
|
|
477
546
|
if (
|
|
478
547
|
sample
|
|
479
|
-
&& intervalBucketKey(sample.timestamp, interval) !== firstBucket
|
|
548
|
+
&& intervalBucketKey(sample.timestamp, interval, clock) !== firstBucket
|
|
480
549
|
&& sample.timestamp < lastTimestamp
|
|
481
550
|
&& sample.ratio < 1
|
|
482
551
|
) {
|
|
@@ -484,7 +553,7 @@ function layoutTimeAxis({
|
|
|
484
553
|
}
|
|
485
554
|
continue;
|
|
486
555
|
}
|
|
487
|
-
const bucket = intervalBucketKey(boundary, interval);
|
|
556
|
+
const bucket = intervalBucketKey(boundary, interval, clock);
|
|
488
557
|
if (bucket === firstBucket) continue;
|
|
489
558
|
addCandidate(candidates, {
|
|
490
559
|
timestamp: boundary,
|
|
@@ -501,7 +570,7 @@ function layoutTimeAxis({
|
|
|
501
570
|
candidates.sort((left, right) => left.ratio - right.ratio || left.timestamp - right.timestamp);
|
|
502
571
|
|
|
503
572
|
if (candidates.length === 1 || firstTimestamp === lastTimestamp) {
|
|
504
|
-
const label = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval);
|
|
573
|
+
const label = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval, clock);
|
|
505
574
|
const visibleLabel = label.slice(0, axisWidth);
|
|
506
575
|
const start = Math.max(Math.floor((axisWidth - visibleLabel.length) / 2), 0);
|
|
507
576
|
writeLabel(axis, start, visibleLabel);
|
|
@@ -517,22 +586,22 @@ function layoutTimeAxis({
|
|
|
517
586
|
};
|
|
518
587
|
}
|
|
519
588
|
|
|
520
|
-
let startLabel = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval);
|
|
521
|
-
let endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval);
|
|
589
|
+
let startLabel = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval, clock);
|
|
590
|
+
let endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, clock);
|
|
522
591
|
if (startLabel.length + endLabel.length + 1 > axisWidth) {
|
|
523
|
-
startLabel = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval, false);
|
|
524
|
-
endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, false);
|
|
592
|
+
startLabel = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval, clock, false);
|
|
593
|
+
endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, clock, false);
|
|
525
594
|
}
|
|
526
595
|
if (startLabel.length + endLabel.length + 1 > axisWidth) {
|
|
527
596
|
// A standalone same-year chart still needs its year. Shorten the right
|
|
528
597
|
// endpoint first instead of dropping that context from both boundaries.
|
|
529
|
-
if (new Date(firstTimestamp).getUTCFullYear() === new Date(lastTimestamp).getUTCFullYear()) {
|
|
530
|
-
endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, false, false);
|
|
598
|
+
if (new Date(clock.wall(firstTimestamp)).getUTCFullYear() === new Date(clock.wall(lastTimestamp)).getUTCFullYear()) {
|
|
599
|
+
endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, clock, false, false);
|
|
531
600
|
}
|
|
532
601
|
}
|
|
533
602
|
if (startLabel.length + endLabel.length + 1 > axisWidth) {
|
|
534
|
-
startLabel = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval, false, false);
|
|
535
|
-
endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, false, false);
|
|
603
|
+
startLabel = formatBoundaryLabel(firstTimestamp, lastTimestamp, interval, clock, false, false);
|
|
604
|
+
endLabel = formatBoundaryLabel(lastTimestamp, firstTimestamp, interval, clock, false, false);
|
|
536
605
|
}
|
|
537
606
|
if (startLabel.length + endLabel.length + 1 > axisWidth) {
|
|
538
607
|
const visibleLabel = startLabel.slice(0, axisWidth);
|
|
@@ -573,7 +642,7 @@ function layoutTimeAxis({
|
|
|
573
642
|
let previousTimestamp = firstTimestamp;
|
|
574
643
|
let previousEnd = startLabel.length - 1;
|
|
575
644
|
for (const candidate of candidates.filter((entry) => !entry.boundary)) {
|
|
576
|
-
const label = formatInteriorLabel(candidate.timestamp, previousTimestamp, interval);
|
|
645
|
+
const label = formatInteriorLabel(candidate.timestamp, previousTimestamp, interval, clock);
|
|
577
646
|
// A year boundary inside the final year would repeat the right endpoint's year.
|
|
578
647
|
if (label === endLabel) continue;
|
|
579
648
|
const center = candidate.ratio * Math.max(axisWidth - 1, 0);
|
|
@@ -620,6 +689,7 @@ export function buildCompositeTimeAxisLayout(
|
|
|
620
689
|
? normalizeMarketSamples(scene)
|
|
621
690
|
: undefined,
|
|
622
691
|
timeRatio: (timestamp) => projectCompositeTimestamp(scene.timeScale, timestamp)?.ratio ?? Number.NaN,
|
|
692
|
+
timeZone: scene.timeZone,
|
|
623
693
|
});
|
|
624
694
|
}
|
|
625
695
|
|
|
@@ -387,7 +387,7 @@ export function summarizeZoomSelection(
|
|
|
387
387
|
|
|
388
388
|
/** The same preview for two times, in either order. */
|
|
389
389
|
export function summarizeZoomRange(
|
|
390
|
-
scene: Pick<CompositeChartScene, "startTime" | "endTime">,
|
|
390
|
+
scene: Pick<CompositeChartScene, "startTime" | "endTime" | "timeZone">,
|
|
391
391
|
first: number,
|
|
392
392
|
second: number,
|
|
393
393
|
): string | null {
|
|
@@ -398,6 +398,7 @@ export function summarizeZoomRange(
|
|
|
398
398
|
new Date(timestamp),
|
|
399
399
|
scene.startTime,
|
|
400
400
|
scene.endTime,
|
|
401
|
+
scene.timeZone,
|
|
401
402
|
);
|
|
402
403
|
return `${label(start)} → ${label(end)} · ${formatMeasureSpan(end - start)}`;
|
|
403
404
|
}
|
|
@@ -132,6 +132,8 @@ export interface CompositePanelScene {
|
|
|
132
132
|
volumeProfile?: CompositeVolumeProfile;
|
|
133
133
|
/** Plot spans (x ratios) of intraday bars outside the regular session, shared by every panel. */
|
|
134
134
|
extendedHours?: readonly CompositeExtendedHoursSpan[];
|
|
135
|
+
/** The previous session's close on a one-day chart, drawn as a dotted reference line. */
|
|
136
|
+
priorClose?: { axis: CompositeAxisSide; value: number; yRatio: number };
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
export interface CompositeCursorValue {
|
|
@@ -156,6 +158,8 @@ export interface CompositeChartScene {
|
|
|
156
158
|
cursorDate: Date | null;
|
|
157
159
|
cursorXRatio: number | null;
|
|
158
160
|
cursorValues: CompositeCursorValue[];
|
|
161
|
+
/** IANA zone the time axis and cursor read in: the venue's for intraday market bars, else UTC. */
|
|
162
|
+
timeZone?: string;
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
export interface BuildCompositeChartSceneOptions {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
adoptFirstPositionCurrency,
|
|
19
19
|
createManualPortfolio as createManualPortfolioConfig,
|
|
20
20
|
deleteManualPortfolio,
|
|
21
|
+
deleteWatchlist as deleteWatchlistConfig,
|
|
21
22
|
hasOpenPortfolioPositions,
|
|
22
23
|
isManualPortfolio,
|
|
23
24
|
resolveManualPositionCurrency,
|
|
@@ -174,15 +175,16 @@ export function createCommandBarCollectionWorkflowActions(options: {
|
|
|
174
175
|
|
|
175
176
|
async deleteWatchlist(watchlistId) {
|
|
176
177
|
const currentState = getState();
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
const { config: nextConfig, watchlist, tickers } = deleteWatchlistConfig(
|
|
179
|
+
currentState.config,
|
|
180
|
+
currentState.tickers.values(),
|
|
181
|
+
watchlistId,
|
|
182
|
+
);
|
|
183
|
+
for (const ticker of tickers) {
|
|
184
|
+
await tickerRepository.saveTicker(ticker);
|
|
185
|
+
dispatch({ type: "UPDATE_TICKER", ticker });
|
|
180
186
|
}
|
|
181
187
|
|
|
182
|
-
const nextConfig = {
|
|
183
|
-
...currentState.config,
|
|
184
|
-
watchlists: currentState.config.watchlists.filter((entry) => entry.id !== watchlistId),
|
|
185
|
-
};
|
|
186
188
|
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
187
189
|
if (activeCollectionId === watchlistId) {
|
|
188
190
|
const fallback = nextConfig.portfolios[0]?.id || nextConfig.watchlists[0]?.id || "";
|
|
@@ -430,6 +430,31 @@ export function Shell({
|
|
|
430
430
|
|
|
431
431
|
const dockLeafLayouts = useMemo(() => getDockLeafLayouts(activeLayout, bounds, dockGeometryOptions), [activeLayout, bounds, dockGeometryOptions]);
|
|
432
432
|
const dockDividerLayouts = useMemo(() => getDockDividerLayouts(activeLayout, bounds, dockGeometryOptions), [activeLayout, bounds, dockGeometryOptions]);
|
|
433
|
+
// What is on screen. In fullscreen that is one pane filling the content
|
|
434
|
+
// area, while the layout above still holds the tiled and floating rects it
|
|
435
|
+
// returns to. Everything drawn, hit tested or cut out over the panes (the
|
|
436
|
+
// focus border, the pointer, kitty image occluders) reads these, or it lands
|
|
437
|
+
// on cells the fullscreen pane now owns.
|
|
438
|
+
const fullscreenRect = useMemo(() => ({ x: 0, y: 0, width, height: contentHeight }), [contentHeight, width]);
|
|
439
|
+
const screenDockLeafLayouts = useMemo(() => (
|
|
440
|
+
transientFocusActive
|
|
441
|
+
? dockLeafLayouts.filter((leaf) => leaf.instanceId === transientFocusPaneId).map((leaf) => ({ ...leaf, rect: fullscreenRect }))
|
|
442
|
+
: dockLeafLayouts
|
|
443
|
+
), [dockLeafLayouts, fullscreenRect, transientFocusActive, transientFocusPaneId]);
|
|
444
|
+
const screenFloatingPanes = useMemo(() => (
|
|
445
|
+
transientFocusActive
|
|
446
|
+
? visibleFloatingPanes.filter(({ pane }) => pane.instance.instanceId === transientFocusPaneId).map((entry) => ({ ...entry, rect: fullscreenRect }))
|
|
447
|
+
: visibleFloatingPanes
|
|
448
|
+
), [fullscreenRect, transientFocusActive, transientFocusPaneId, visibleFloatingPanes]);
|
|
449
|
+
const screenDockedPanes = useMemo(() => (
|
|
450
|
+
transientFocusActive
|
|
451
|
+
? dockedPanes.filter((pane) => pane.instance.instanceId === transientFocusPaneId)
|
|
452
|
+
: dockedPanes
|
|
453
|
+
), [dockedPanes, transientFocusActive, transientFocusPaneId]);
|
|
454
|
+
const screenDividerLayouts = useMemo(
|
|
455
|
+
() => (transientFocusActive ? [] : dockDividerLayouts),
|
|
456
|
+
[dockDividerLayouts, transientFocusActive],
|
|
457
|
+
);
|
|
433
458
|
const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
|
|
434
459
|
const externalDockPreview = useMemo(
|
|
435
460
|
() => resolveExternalDockPreview(desktopDockPreview, bounds),
|
|
@@ -448,13 +473,13 @@ export function Shell({
|
|
|
448
473
|
contentHeight,
|
|
449
474
|
dialogOpen,
|
|
450
475
|
dividerPreview,
|
|
451
|
-
dockDividerLayouts,
|
|
452
|
-
dockedPanes,
|
|
476
|
+
dockDividerLayouts: screenDividerLayouts,
|
|
477
|
+
dockedPanes: screenDockedPanes,
|
|
453
478
|
dragFloatingRect,
|
|
454
479
|
effectiveDockPreview,
|
|
455
480
|
menuState,
|
|
456
481
|
nativeWindowModePanelRect,
|
|
457
|
-
visibleFloatingPanes,
|
|
482
|
+
visibleFloatingPanes: screenFloatingPanes,
|
|
458
483
|
width,
|
|
459
484
|
windowModeDockMovePreview,
|
|
460
485
|
});
|
|
@@ -528,14 +553,12 @@ export function Shell({
|
|
|
528
553
|
const openPaneMenuRef = useRef<((paneId: string, rect: LayoutBounds, event?: undefined, options?: { keyboard?: boolean }) => void) | null>(null);
|
|
529
554
|
const openFocusedPaneMenu = useCallback(() => {
|
|
530
555
|
if (!focusedPaneId || windowMode) return false;
|
|
531
|
-
const rect =
|
|
532
|
-
|
|
533
|
-
: dockLeafLayouts.find((leaf) => leaf.instanceId === focusedPaneId)?.rect
|
|
534
|
-
?? visibleFloatingPanes.find(({ pane }) => pane.instance.instanceId === focusedPaneId)?.rect;
|
|
556
|
+
const rect = screenDockLeafLayouts.find((leaf) => leaf.instanceId === focusedPaneId)?.rect
|
|
557
|
+
?? screenFloatingPanes.find(({ pane }) => pane.instance.instanceId === focusedPaneId)?.rect;
|
|
535
558
|
if (!rect || !openPaneMenuRef.current) return false;
|
|
536
559
|
openPaneMenuRef.current(focusedPaneId, rect, undefined, { keyboard: true });
|
|
537
560
|
return true;
|
|
538
|
-
}, [
|
|
561
|
+
}, [focusedPaneId, screenDockLeafLayouts, screenFloatingPanes, windowMode]);
|
|
539
562
|
|
|
540
563
|
useShellPaneManagementShortcuts({
|
|
541
564
|
cancelActiveDrag,
|
|
@@ -706,21 +729,6 @@ export function Shell({
|
|
|
706
729
|
if (menuState && !paneMap.has(menuState.paneId)) closePaneMenu();
|
|
707
730
|
}, [closePaneMenu, menuState, paneMap]);
|
|
708
731
|
|
|
709
|
-
// In fullscreen the pointer only ever meets the one pane on screen: hit
|
|
710
|
-
// testing the tiled rects behind it would focus a pane nobody can see.
|
|
711
|
-
const fullscreenRect = useMemo(() => ({ x: 0, y: 0, width, height: contentHeight }), [contentHeight, width]);
|
|
712
|
-
const pointerDockLeafLayouts = useMemo(() => (
|
|
713
|
-
transientFocusActive
|
|
714
|
-
? dockLeafLayouts.filter((leaf) => leaf.instanceId === transientFocusPaneId).map((leaf) => ({ ...leaf, rect: fullscreenRect }))
|
|
715
|
-
: dockLeafLayouts
|
|
716
|
-
), [dockLeafLayouts, fullscreenRect, transientFocusActive, transientFocusPaneId]);
|
|
717
|
-
const pointerFloatingPanes = useMemo(() => (
|
|
718
|
-
transientFocusActive
|
|
719
|
-
? visibleFloatingPanes.filter(({ pane }) => pane.instance.instanceId === transientFocusPaneId).map((entry) => ({ ...entry, rect: fullscreenRect }))
|
|
720
|
-
: visibleFloatingPanes
|
|
721
|
-
), [fullscreenRect, transientFocusActive, transientFocusPaneId, visibleFloatingPanes]);
|
|
722
|
-
const pointerDividerLayouts = transientFocusActive ? [] : dockDividerLayouts;
|
|
723
|
-
|
|
724
732
|
const {
|
|
725
733
|
handleFloatingCloseMouseDown,
|
|
726
734
|
handleMouse,
|
|
@@ -738,8 +746,10 @@ export function Shell({
|
|
|
738
746
|
closePaneMenu,
|
|
739
747
|
contentHeight,
|
|
740
748
|
dockGeometryOptions,
|
|
741
|
-
|
|
742
|
-
|
|
749
|
+
// In fullscreen the pointer only ever meets the one pane on screen: hit
|
|
750
|
+
// testing the tiled rects behind it would focus a pane nobody can see.
|
|
751
|
+
dockDividerLayouts: screenDividerLayouts,
|
|
752
|
+
dockLeafLayouts: screenDockLeafLayouts,
|
|
743
753
|
dragRuntime,
|
|
744
754
|
focusPane,
|
|
745
755
|
focusedPaneId,
|
|
@@ -756,7 +766,7 @@ export function Shell({
|
|
|
756
766
|
snapGuides,
|
|
757
767
|
transientFocusActive,
|
|
758
768
|
updateWindowModePreviewLayout,
|
|
759
|
-
visibleFloatingPanes:
|
|
769
|
+
visibleFloatingPanes: screenFloatingPanes,
|
|
760
770
|
visibleLayout,
|
|
761
771
|
width,
|
|
762
772
|
windowMode,
|
|
@@ -851,7 +861,7 @@ export function Shell({
|
|
|
851
861
|
bounds={bounds}
|
|
852
862
|
contentHeight={contentHeight}
|
|
853
863
|
dockGeometryOptions={dockGeometryOptions}
|
|
854
|
-
dockLeafLayouts={
|
|
864
|
+
dockLeafLayouts={screenDockLeafLayouts}
|
|
855
865
|
dragFloatingRect={dragFloatingRect}
|
|
856
866
|
focusedPaneId={focusedPaneId}
|
|
857
867
|
getPaneTitle={getPaneTitle}
|
|
@@ -860,7 +870,7 @@ export function Shell({
|
|
|
860
870
|
nativeWindowModePanelRect={nativeWindowModePanelRect}
|
|
861
871
|
overlayOpen={overlayOpen}
|
|
862
872
|
paneMap={paneMap}
|
|
863
|
-
visibleFloatingPanes={
|
|
873
|
+
visibleFloatingPanes={screenFloatingPanes}
|
|
864
874
|
width={width}
|
|
865
875
|
windowMode={windowMode}
|
|
866
876
|
windowModeDockMovePreview={windowModeDockMovePreview}
|
|
@@ -24,14 +24,20 @@ const FIELD_LABELS: Record<PositionFieldId, string> = {
|
|
|
24
24
|
ticker: "Ticker",
|
|
25
25
|
shares: "Shares",
|
|
26
26
|
avgCost: "Avg cost",
|
|
27
|
+
currency: "Currency",
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
const FIELD_PLACEHOLDERS: Record<PositionFieldId, string> = {
|
|
30
|
+
const FIELD_PLACEHOLDERS: Record<Exclude<PositionFieldId, "currency">, string> = {
|
|
30
31
|
ticker: "e.g. AAPL",
|
|
31
32
|
shares: "optional",
|
|
32
33
|
avgCost: "current price",
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
/** A blank Currency shows the code it will save, as the AP form does. */
|
|
37
|
+
function fieldPlaceholder(field: PositionFieldId, state: OnboardingPositionsState): string {
|
|
38
|
+
return field === "currency" ? state.blankCurrency : t(FIELD_PLACEHOLDERS[field]);
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
/** Company name and last price for the symbol being typed, or why it will not resolve. */
|
|
36
42
|
function PreviewLine({ preview, error }: Pick<OnboardingPositionsState, "preview" | "error">) {
|
|
37
43
|
const colors = useThemeColors();
|
|
@@ -163,7 +169,7 @@ function DesktopPositionsPanel({
|
|
|
163
169
|
label: t(FIELD_LABELS[field]),
|
|
164
170
|
inputRef: index === state.fieldIdx ? inputRef : undefined,
|
|
165
171
|
value: state.draft[field],
|
|
166
|
-
placeholder:
|
|
172
|
+
placeholder: fieldPlaceholder(field, state),
|
|
167
173
|
focused: editing && index === state.fieldIdx && !state.submitting,
|
|
168
174
|
size: "comfortable" as const,
|
|
169
175
|
backgroundColor: colors.panel,
|
|
@@ -184,6 +190,9 @@ function DesktopPositionsPanel({
|
|
|
184
190
|
<Box style={{ width: 128, flexShrink: 0 }}>
|
|
185
191
|
<NumberField {...fieldProps("avgCost", 2)} onChange={(value) => state.setField("avgCost", value)} />
|
|
186
192
|
</Box>
|
|
193
|
+
<Box style={{ width: 80, flexShrink: 0 }}>
|
|
194
|
+
<TextField {...fieldProps("currency", 3)} onChange={(value) => state.setField("currency", value)} />
|
|
195
|
+
</Box>
|
|
187
196
|
<Box style={{ flexShrink: 0 }}>
|
|
188
197
|
<Button
|
|
189
198
|
label={state.submitting ? "Adding..." : "Add"}
|
|
@@ -229,7 +238,7 @@ function TuiFieldRow({
|
|
|
229
238
|
const colors = useThemeColors();
|
|
230
239
|
const active = editing && index === state.fieldIdx;
|
|
231
240
|
const value = state.draft[field];
|
|
232
|
-
const Field = field === "ticker" ? TextField : NumberField;
|
|
241
|
+
const Field = field === "ticker" || field === "currency" ? TextField : NumberField;
|
|
233
242
|
return (
|
|
234
243
|
<Box height={1} flexDirection="row">
|
|
235
244
|
<Box width={10} flexShrink={0}>
|
|
@@ -242,7 +251,7 @@ function TuiFieldRow({
|
|
|
242
251
|
<Field
|
|
243
252
|
inputRef={inputRef}
|
|
244
253
|
value={value}
|
|
245
|
-
placeholder={
|
|
254
|
+
placeholder={fieldPlaceholder(field, state)}
|
|
246
255
|
focused={!state.submitting}
|
|
247
256
|
backgroundColor={colors.panel}
|
|
248
257
|
textColor={colors.text}
|
|
@@ -250,7 +259,7 @@ function TuiFieldRow({
|
|
|
250
259
|
onChange={(next) => state.setField(field, next)}
|
|
251
260
|
/>
|
|
252
261
|
) : (
|
|
253
|
-
<Text fg={value ? colors.text : colors.textMuted}>{value ||
|
|
262
|
+
<Text fg={value ? colors.text : colors.textMuted}>{value || fieldPlaceholder(field, state)}</Text>
|
|
254
263
|
)}
|
|
255
264
|
</Box>
|
|
256
265
|
{field === "ticker" ? (
|
|
@@ -5,6 +5,7 @@ import type { AppConfig, OnboardingProgress, OnboardingStage } from "../../types
|
|
|
5
5
|
import type { Quote } from "../../types/financials";
|
|
6
6
|
import type { TickerRecord } from "../../types/ticker";
|
|
7
7
|
import { getManualPortfolioPosition } from "../../plugins/builtin/portfolio-list/mutations";
|
|
8
|
+
import { resolveCurrencyUnit } from "../../utils/currency-units";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Stages older builds persisted that the wizard no longer renders. A profile
|
|
@@ -96,18 +97,25 @@ export function listOnboardingPositions(
|
|
|
96
97
|
for (const ticker of tickers) {
|
|
97
98
|
if (!ticker.metadata.portfolios.includes(portfolioId)) continue;
|
|
98
99
|
const position = getManualPortfolioPosition(ticker, portfolioId);
|
|
99
|
-
const
|
|
100
|
+
const quote = quotes(ticker.metadata.ticker);
|
|
101
|
+
const price = typeof quote?.price === "number" && Number.isFinite(quote.price) ? quote.price : null;
|
|
100
102
|
const shares = position?.shares ?? null;
|
|
101
103
|
const avgCost = position?.avgCost ?? null;
|
|
102
|
-
const
|
|
104
|
+
const costCurrency = position?.currency || ticker.metadata.currency || "USD";
|
|
105
|
+
// Priced, the row is worth the quote in the listing's own currency (pence
|
|
106
|
+
// in pounds), whatever currency the cost was entered in.
|
|
107
|
+
const quoteUnit = price !== null ? resolveCurrencyUnit(quote?.currency || costCurrency) : null;
|
|
103
108
|
rows.push({
|
|
104
109
|
symbol: ticker.metadata.ticker,
|
|
105
110
|
name: ticker.metadata.name || "",
|
|
106
|
-
currency:
|
|
111
|
+
currency: quoteUnit?.currency || costCurrency,
|
|
107
112
|
shares,
|
|
108
113
|
avgCost,
|
|
109
|
-
price
|
|
110
|
-
value: shares
|
|
114
|
+
price,
|
|
115
|
+
value: shares === null ? null
|
|
116
|
+
: price !== null ? shares * price / quoteUnit!.divisor
|
|
117
|
+
: avgCost !== null ? shares * avgCost
|
|
118
|
+
: null,
|
|
111
119
|
});
|
|
112
120
|
}
|
|
113
121
|
return rows;
|