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.
Files changed (77) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/api-client/data.ts +5 -0
  4. package/src/api-client/doe.ts +75 -0
  5. package/src/api-client/index.ts +1 -0
  6. package/src/api-client/paths.ts +3 -0
  7. package/src/api-client/types.ts +2 -0
  8. package/src/cli/pane-functions/function-help.ts +15 -0
  9. package/src/components/chart/composite/composite-chart.tsx +15 -2
  10. package/src/components/chart/composite/format.ts +24 -11
  11. package/src/components/chart/composite/rasterizer.ts +67 -16
  12. package/src/components/chart/composite/scene.ts +53 -2
  13. package/src/components/chart/composite/session-shading.ts +3 -2
  14. package/src/components/chart/composite/text-renderer.ts +52 -1
  15. package/src/components/chart/composite/time-axis.ts +100 -30
  16. package/src/components/chart/composite/tools.ts +2 -1
  17. package/src/components/chart/composite/types.ts +4 -0
  18. package/src/components/command-bar/workflow/collection-actions.ts +9 -7
  19. package/src/components/layout/shell/index.tsx +38 -28
  20. package/src/components/onboarding/portfolio-step/positions-panel.tsx +14 -5
  21. package/src/components/onboarding/wizard-model.ts +13 -5
  22. package/src/components/onboarding/wizard-positions.ts +56 -7
  23. package/src/components/ui/display.tsx +6 -3
  24. package/src/components/ui/fields.tsx +5 -0
  25. package/src/components/ui/header-case.ts +25 -3
  26. package/src/components/ui/query-bar.tsx +8 -1
  27. package/src/i18n/es.ts +1 -0
  28. package/src/i18n/ja.ts +1 -0
  29. package/src/i18n/ko.ts +1 -0
  30. package/src/i18n/zh-cn.ts +1 -0
  31. package/src/i18n/zh-tw.ts +1 -0
  32. package/src/market-data/market/trading-sessions.ts +65 -1
  33. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -0
  34. package/src/plugins/builtin/chart-composer/headless-schema.ts +6 -0
  35. package/src/plugins/builtin/chart-composer/pane.tsx +28 -1
  36. package/src/plugins/builtin/composite-plugins.ts +2 -0
  37. package/src/plugins/builtin/doe/client.ts +73 -0
  38. package/src/plugins/builtin/doe/headless.ts +93 -0
  39. package/src/plugins/builtin/doe/index.tsx +41 -0
  40. package/src/plugins/builtin/doe/model.ts +215 -0
  41. package/src/plugins/builtin/doe/pane.tsx +148 -0
  42. package/src/plugins/builtin/doe/range-bar.tsx +45 -0
  43. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +7 -7
  44. package/src/plugins/builtin/executives/pane.tsx +4 -4
  45. package/src/plugins/builtin/fear-greed/charts.tsx +1 -1
  46. package/src/plugins/builtin/holders/pane.tsx +1 -1
  47. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  48. package/src/plugins/builtin/options/view.tsx +1 -1
  49. package/src/plugins/builtin/portfolio-list/cli/render.ts +1 -1
  50. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -20
  51. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -2
  52. package/src/plugins/builtin/portfolio-list/mutations.ts +53 -1
  53. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +6 -6
  54. package/src/plugins/builtin/risk-factors/pane.tsx +4 -4
  55. package/src/plugins/builtin/shared/ticker-request.ts +1 -1
  56. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +42 -10
  57. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +1 -1
  58. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +1 -1
  59. package/src/plugins/catalog-browser.ts +2 -0
  60. package/src/sources/gloomberb-cloud/index.ts +47 -13
  61. package/src/sources/history-result.ts +1 -0
  62. package/src/sources/history-retention.ts +16 -0
  63. package/src/sources/provider-router/history.ts +4 -2
  64. package/src/sync/core-contributors.ts +18 -2
  65. package/src/tickers/open-target.ts +1 -1
  66. package/src/tickers/search/index.ts +44 -11
  67. package/src/tickers/search/upsert.ts +4 -1
  68. package/src/time-series/live-bars.ts +8 -3
  69. package/src/time-series/resolve.ts +224 -17
  70. package/src/time-series/spec.ts +1 -0
  71. package/src/time-series/types.ts +9 -1
  72. package/src/types/data-provider.ts +2 -0
  73. package/src/types/price-history.ts +2 -0
  74. package/src/utils/exchanges.ts +1 -0
  75. package/src/utils/price-history.ts +35 -1
  76. package/src/utils/sec.ts +2 -1
  77. 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
- timestamp: number,
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 sameUtcDay(left: number, right: number): boolean {
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 sameUtcMonth(left: number, right: number): boolean {
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
- timestamp: number,
342
- counterpart: number,
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 ? " UTC" : "";
355
- if (sameUtcDay(timestamp, counterpart)) return `${clock}${suffix}`;
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
- timestamp: number,
372
- previousTimestamp: number,
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 (sameUtcDay(timestamp, previousTimestamp)) return clock;
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 (sameUtcMonth(timestamp, previousTimestamp)) return `${date.getUTCDate()}`;
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 interval = resolveTimeAxisInterval(startTime, endTime, axisWidth);
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 watchlist = currentState.config.watchlists.find((entry) => entry.id === watchlistId);
178
- if (!watchlist) {
179
- throw new Error("Watchlist not found.");
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 = transientFocusActive && transientFocusPaneId === focusedPaneId
532
- ? { x: 0, y: 0, width, height: contentHeight }
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
- }, [contentHeight, dockLeafLayouts, focusedPaneId, transientFocusActive, transientFocusPaneId, visibleFloatingPanes, width, windowMode]);
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
- dockDividerLayouts: pointerDividerLayouts,
742
- dockLeafLayouts: pointerDockLeafLayouts,
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: pointerFloatingPanes,
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={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={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: t(FIELD_PLACEHOLDERS[field]),
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={t(FIELD_PLACEHOLDERS[field])}
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 || t(FIELD_PLACEHOLDERS[field])}</Text>
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 price = quotes(ticker.metadata.ticker)?.price;
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 basis = typeof price === "number" && Number.isFinite(price) ? price : avgCost;
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: position?.currency ?? ticker.metadata.currency ?? "USD",
111
+ currency: quoteUnit?.currency || costCurrency,
107
112
  shares,
108
113
  avgCost,
109
- price: typeof price === "number" && Number.isFinite(price) ? price : null,
110
- value: shares !== null && basis !== null ? shares * basis : null,
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;