gloomberb 0.15.1 → 0.15.2

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/bin/gloomberb CHANGED
@@ -1,3 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import "../src/cli/entry.ts"
3
+ // Bun applies patchedDependencies only to the root project, so a global
4
+ // `bun install -g gloomberb` runs react-reconciler without
5
+ // patches/react-reconciler@0.33.0.patch. Its dev build then records a
6
+ // performance.measure for every component render, which Bun keeps forever.
7
+ // The reconciler reads console.timeStamp once at load to decide, so hide it
8
+ // before React is imported.
9
+ delete console.timeStamp;
10
+
11
+ await import("../src/cli/entry.ts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -8,6 +8,8 @@ export async function smokeOpenTuiNative(): Promise<void> {
8
8
 
9
9
  export async function smokeOpenTuiRuntime(): Promise<void> {
10
10
  await import("../renderers/opentui/start");
11
+ const { assertRendersLeaveNoTimingEntries } = await import("../renderers/opentui/render-timing-smoke");
12
+ await assertRendersLeaveNoTimingEntries();
11
13
  }
12
14
 
13
15
  /**
@@ -22,7 +22,8 @@ export { Menu, MenuPopover } from "./menu";
22
22
  export type { MenuItem, MenuProps, MenuPopoverProps } from "./menu";
23
23
 
24
24
  export { MultiSelectDialogButton, MultiSelectDialogContent } from "./multi-select/dialog";
25
- export type { MultiSelectDialogButtonHandle, MultiSelectPopoverAnchorPoint } from "./multi-select/dialog";
25
+ export type { MultiSelectDialogButtonHandle, MultiSelectPopoverAnchorPoint, MultiSelectRowAction } from "./multi-select/dialog";
26
+ export { NumberPromptDialog } from "./number-prompt-dialog";
26
27
 
27
28
  export { SelectButton } from "./select-button";
28
29
  export type { SelectButtonOption, SelectButtonProps, SelectControl } from "./select-button";
@@ -37,6 +37,19 @@ import {
37
37
  type MultiSelectOption,
38
38
  } from "./index";
39
39
 
40
+ /**
41
+ * One action on the highlighted row, shown beside Done: editing a value the
42
+ * option carries, such as an indicator's period.
43
+ */
44
+ export interface MultiSelectRowAction {
45
+ label: string;
46
+ shortcut: string;
47
+ /** Whether the highlighted option offers it; `selected` is its checkbox. */
48
+ appliesTo(value: string, selected: boolean): boolean;
49
+ /** A returned label replaces the row's label while the dialog stays open. */
50
+ run(value: string): Promise<string | void> | string | void;
51
+ }
52
+
40
53
  export interface MultiSelectDialogContentProps extends AlertContext {
41
54
  title: string;
42
55
  options: MultiSelectOption[];
@@ -45,6 +58,7 @@ export interface MultiSelectDialogContentProps extends AlertContext {
45
58
  ordered?: boolean;
46
59
  emptyLabel?: string;
47
60
  idPrefix?: string;
61
+ rowAction?: MultiSelectRowAction;
48
62
  }
49
63
 
50
64
  export interface MultiSelectDialogButtonProps {
@@ -61,6 +75,8 @@ export interface MultiSelectDialogButtonProps {
61
75
  shortcutKey?: string | string[];
62
76
  shortcutActive?: boolean;
63
77
  onOpenChange?: (open: boolean) => void;
78
+ /** Opens the dialog on desktop too, since the popover menu has no row actions. */
79
+ rowAction?: MultiSelectRowAction;
64
80
  }
65
81
 
66
82
  export interface MultiSelectPopoverAnchorPoint {
@@ -197,6 +213,7 @@ export function MultiSelectDialogContent({
197
213
  onChange,
198
214
  ordered = false,
199
215
  idPrefix,
216
+ rowAction,
200
217
  }: MultiSelectDialogContentProps) {
201
218
  const isDesktopWeb = useUiHost().kind === "desktop-web";
202
219
  const optionByValue = useMemo(() => new Map(options.map((option) => [option.value, option])), [options]);
@@ -216,6 +233,11 @@ export function MultiSelectDialogContent({
216
233
  const canMoveDown = ordered
217
234
  && selectedValueOrder >= 0
218
235
  && selectedValueOrder < knownSelectedValues.length - 1;
236
+ const [labelOverrides, setLabelOverrides] = useState<Record<string, string>>({});
237
+ const canRunRowAction = !!rowAction
238
+ && !!selectedOption
239
+ && !selectedOption.disabled
240
+ && rowAction.appliesTo(selectedOption.value, selectedValues.includes(selectedOption.value));
219
241
 
220
242
  useEffect(() => {
221
243
  setSelectedValues((values) => normalizeDialogSelectedValues(options, values, ordered));
@@ -235,7 +257,7 @@ export function MultiSelectDialogContent({
235
257
 
236
258
  return {
237
259
  id: option.value,
238
- label: option.label,
260
+ label: labelOverrides[option.value] ?? option.label,
239
261
  disabled: option.disabled,
240
262
  enabled: selectedValues.includes(option.value),
241
263
  description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
@@ -278,6 +300,13 @@ export function MultiSelectDialogContent({
278
300
  );
279
301
  };
280
302
 
303
+ const runRowAction = async () => {
304
+ if (!rowAction || !canRunRowAction || !selectedOption) return;
305
+ const value = selectedOption.value;
306
+ const label = await rowAction.run(value);
307
+ if (typeof label === "string") setLabelOverrides((current) => ({ ...current, [value]: label }));
308
+ };
309
+
281
310
  useDialogKeyboard((event) => {
282
311
  event.stopPropagation();
283
312
  const move = listCursorMove(event, pageSize);
@@ -290,6 +319,8 @@ export function MultiSelectDialogContent({
290
319
  void moveOption("up").catch(() => {});
291
320
  } else if (event.name === "]" && ordered) {
292
321
  void moveOption("down").catch(() => {});
322
+ } else if (rowAction && event.name === rowAction.shortcut && !event.ctrl && !event.meta && !event.alt) {
323
+ void runRowAction().catch(() => {});
293
324
  } else if (event.name === "enter" || event.name === "return" || event.name === "escape" || isDetailBackNavigationKey(event)) {
294
325
  dismiss();
295
326
  }
@@ -331,6 +362,15 @@ export function MultiSelectDialogContent({
331
362
  <Button label="Move Down" shortcut="]" variant="ghost" disabled={!canMoveDown} onPress={() => { void moveOption("down").catch(() => {}); }} />
332
363
  </>
333
364
  )}
365
+ {rowAction && (
366
+ <Button
367
+ label={rowAction.label}
368
+ shortcut={rowAction.shortcut}
369
+ variant="ghost"
370
+ disabled={!canRunRowAction}
371
+ onPress={() => { void runRowAction().catch(() => {}); }}
372
+ />
373
+ )}
334
374
  <Button label="Done" shortcut="Enter" variant="primary" onPress={dismiss} />
335
375
  </Box>
336
376
  </Box>
@@ -352,6 +392,7 @@ function MultiSelectDialogButtonInner({
352
392
  shortcutKey,
353
393
  shortcutActive = false,
354
394
  onOpenChange,
395
+ rowAction,
355
396
  }: MultiSelectDialogButtonProps, ref: ForwardedRef<MultiSelectDialogButtonHandle>) {
356
397
  const isDesktopWeb = useUiHost().kind === "desktop-web";
357
398
  const dialog = useDialog();
@@ -364,7 +405,7 @@ function MultiSelectDialogButtonInner({
364
405
  const openDialog = useCallback((event?: DialogTriggerEvent, anchorPoint?: MultiSelectPopoverAnchorPoint) => {
365
406
  stopMouseEvent(event);
366
407
  if (disabled) return;
367
- if (isDesktopWeb && !ordered) {
408
+ if (isDesktopWeb && !ordered && !rowAction) {
368
409
  setPopoverAnchorPoint(anchorPoint ?? null);
369
410
  setPopoverOpen(true);
370
411
  onOpenChange?.(true);
@@ -383,10 +424,11 @@ function MultiSelectDialogButtonInner({
383
424
  ordered={ordered}
384
425
  emptyLabel={emptyLabel}
385
426
  idPrefix={idPrefix}
427
+ rowAction={rowAction}
386
428
  />
387
429
  ),
388
430
  }).catch(() => {}).finally(() => onOpenChange?.(false));
389
- }, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, selectedValues, title]);
431
+ }, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, rowAction, selectedValues, title]);
390
432
 
391
433
  const closePopover = useCallback(() => {
392
434
  setPopoverOpen(false);
@@ -469,7 +511,7 @@ function MultiSelectDialogButtonInner({
469
511
  );
470
512
  }
471
513
 
472
- if (isDesktopWeb && !ordered) {
514
+ if (isDesktopWeb && !ordered && !rowAction) {
473
515
  return (
474
516
  <Popover
475
517
  open={popoverOpen}
@@ -0,0 +1,71 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Box, Text, type InputRenderable } from "../../ui";
3
+ import type { PromptContext } from "../../ui/dialog";
4
+ import { useThemeColors } from "../../theme/theme-context";
5
+ import { DialogFrame } from "./frame";
6
+ import { TextField } from "./fields";
7
+
8
+ /** A whole number within [min, max], or null for anything else. */
9
+ export function parseWholeNumber(text: string, min: number, max: number): number | null {
10
+ const trimmed = text.trim();
11
+ if (!/^\d+$/.test(trimmed)) return null;
12
+ const value = Number(trimmed);
13
+ return value >= min && value <= max ? value : null;
14
+ }
15
+
16
+ export interface NumberPromptDialogProps extends PromptContext<number> {
17
+ title: string;
18
+ initialValue: number;
19
+ min: number;
20
+ max: number;
21
+ /** Shown under the field when Enter is pressed on something out of range. */
22
+ invalidMessage: string;
23
+ }
24
+
25
+ /**
26
+ * One prefilled number field. Enter keeps a valid value, Esc closes without
27
+ * one (the dialog host resolves the prompt with undefined).
28
+ */
29
+ export function NumberPromptDialog({
30
+ resolve,
31
+ title,
32
+ initialValue,
33
+ min,
34
+ max,
35
+ invalidMessage,
36
+ }: NumberPromptDialogProps) {
37
+ const colors = useThemeColors();
38
+ const inputRef = useRef<InputRenderable>(null);
39
+ const [value, setValue] = useState(String(initialValue));
40
+ const [invalid, setInvalid] = useState(false);
41
+
42
+ useEffect(() => {
43
+ inputRef.current?.focus?.();
44
+ }, []);
45
+
46
+ return (
47
+ <DialogFrame title={title}>
48
+ <Box flexDirection="column" width={32}>
49
+ <TextField
50
+ inputRef={inputRef}
51
+ value={value}
52
+ focused
53
+ onChange={(next) => {
54
+ setValue(next);
55
+ setInvalid(false);
56
+ }}
57
+ onSubmit={(submitted) => {
58
+ const parsed = parseWholeNumber(submitted, min, max);
59
+ if (parsed === null) setInvalid(true);
60
+ else resolve(parsed);
61
+ }}
62
+ />
63
+ {invalid && (
64
+ <Box height={1}>
65
+ <Text fg={colors.negative}>{invalidMessage}</Text>
66
+ </Box>
67
+ )}
68
+ </Box>
69
+ </DialogFrame>
70
+ );
71
+ }
@@ -11,7 +11,9 @@ import {
11
11
  } from "../../../components";
12
12
  import {
13
13
  MultiSelectDialogButton,
14
+ NumberPromptDialog,
14
15
  type MultiSelectDialogButtonHandle,
16
+ type MultiSelectRowAction,
15
17
  } from "../../../components/ui";
16
18
  import { CompositeChart } from "../../../components/chart/composite";
17
19
  import type { PaneProps, TickerResearchTabProps } from "../../../types/plugin";
@@ -58,11 +60,16 @@ import {
58
60
  buildPriceChartPreset,
59
61
  chartSeriesLabel,
60
62
  defaultFinancialTimestampMode,
63
+ builtinStudyPeriod,
61
64
  getSelectedBuiltinStudies,
62
65
  getSelectedPairStudies,
66
+ isPeriodStudy,
63
67
  setBuiltinStudies,
68
+ setBuiltinStudyPeriod,
64
69
  setPairStudies,
65
70
  rebindResearchChartSpec,
71
+ STUDY_PERIOD_MAX,
72
+ STUDY_PERIOD_MIN,
66
73
  type BuiltinStudySelection,
67
74
  type PairStudySelection,
68
75
  } from "./presets";
@@ -71,7 +78,9 @@ import {
71
78
  CHART_FORMULA_OPTIONS,
72
79
  CHART_RANGES as RANGES,
73
80
  CHART_RESOLUTIONS as RESOLUTIONS,
74
- CHART_STUDY_OPTIONS,
81
+ chartStudyLabel,
82
+ chartStudyOptionsFor,
83
+ chartStudyPeriodTitle,
75
84
  } from "./settings";
76
85
  import { resolveChartComposerShortcut } from "./shortcuts";
77
86
  import { describeChartResolution, formatChartDateWindow, formatChartResolution } from "./viewport-labels";
@@ -310,6 +319,37 @@ function ChartComposerSurface({
310
319
  const interactionCaptureRef = useRef(false);
311
320
  const interactionCaptureSourcesRef = useRef(new Set<string>());
312
321
  const indicatorsDialogRef = useRef<MultiSelectDialogButtonHandle | null>(null);
322
+ // The Indicators dialog keeps the callbacks it opened with; reading the spec
323
+ // through this ref keeps a toggle from undoing a period edit made meanwhile.
324
+ const specRef = useRef(spec);
325
+ specRef.current = spec;
326
+ const studyOptions = useMemo(() => chartStudyOptionsFor(spec), [spec]);
327
+ const studyPeriodAction = useMemo<MultiSelectRowAction>(() => ({
328
+ label: "Period",
329
+ shortcut: "p",
330
+ appliesTo: (value, selected) => selected && isPeriodStudy(value),
331
+ run: async (value) => {
332
+ if (!isPeriodStudy(value)) return;
333
+ const current = builtinStudyPeriod(specRef.current, value) ?? STUDY_PERIOD_MIN;
334
+ const next = await dialog.prompt<number>({
335
+ content: (ctx: PromptContext<number>) => (
336
+ <NumberPromptDialog
337
+ {...ctx}
338
+ title={chartStudyPeriodTitle(value)}
339
+ initialValue={current}
340
+ min={STUDY_PERIOD_MIN}
341
+ max={STUDY_PERIOD_MAX}
342
+ invalidMessage={`Whole number from ${STUDY_PERIOD_MIN} to ${STUDY_PERIOD_MAX}`}
343
+ />
344
+ ),
345
+ });
346
+ if (next === undefined || next === current) return;
347
+ const nextSpec = setBuiltinStudyPeriod(specRef.current, value, next);
348
+ specRef.current = nextSpec;
349
+ setSpec(nextSpec);
350
+ return chartStudyLabel(value, next);
351
+ },
352
+ }), [dialog, setSpec]);
313
353
  const formulasDialogRef = useRef<MultiSelectDialogButtonHandle | null>(null);
314
354
  const indicatorsDisabled = !isPriceStudyTarget(spec);
315
355
  const formulasDisabled = spec.series.filter((series) => series.visible !== false).length < 2;
@@ -642,9 +682,14 @@ function ChartComposerSurface({
642
682
  ref={indicatorsDialogRef}
643
683
  label="Indicators"
644
684
  title="Chart Indicators"
645
- options={CHART_STUDY_OPTIONS}
685
+ options={studyOptions}
646
686
  selectedValues={selectedStudies}
647
- onChange={(values) => setSpec(setBuiltinStudies(spec, values as BuiltinStudySelection[]))}
687
+ onChange={(values) => {
688
+ const nextSpec = setBuiltinStudies(specRef.current, values as BuiltinStudySelection[]);
689
+ specRef.current = nextSpec;
690
+ setSpec(nextSpec);
691
+ }}
692
+ rowAction={studyPeriodAction}
648
693
  disabled={indicatorsDisabled}
649
694
  idPrefix={`${footerId}:indicators`}
650
695
  shortcutKey="i"
@@ -653,3 +653,20 @@ describe("chart composer CLI options", () => {
653
653
  });
654
654
  });
655
655
  });
656
+
657
+ describe("indicator periods", () => {
658
+ test("an edited period survives toggling other indicators and names itself in the picker", async () => {
659
+ const { builtinStudyPeriod, setBuiltinStudyPeriod } = await import("./presets");
660
+ const { chartStudyLabel } = await import("./settings");
661
+ const withSma = setBuiltinStudies(buildPriceChartPreset("AAPL"), ["sma50"]);
662
+ const edited = setBuiltinStudyPeriod(withSma, "sma50", 30);
663
+ expect(builtinStudyPeriod(edited, "sma50")).toBe(30);
664
+ // Turning another indicator on and off rebuilds the list from the selection.
665
+ const toggled = setBuiltinStudies(setBuiltinStudies(edited, ["sma50", "rsi14"]), ["sma50"]);
666
+ expect(builtinStudyPeriod(toggled, "sma50")).toBe(30);
667
+ expect(chartStudyLabel("sma50", builtinStudyPeriod(toggled, "sma50"))).toBe("Simple moving average (SMA 30)");
668
+ // An indicator that is not on keeps its default and is left alone.
669
+ expect(setBuiltinStudyPeriod(toggled, "ema20", 9)).toBe(toggled);
670
+ expect(builtinStudyPeriod(toggled, "ema20")).toBe(20);
671
+ });
672
+ });
@@ -794,6 +794,54 @@ export function setBuiltinStudies(spec: ChartSpec, selected: readonly BuiltinStu
794
794
  return { ...spec, studies, panels: reconcilePanels(spec.panels, spec.series, studies) };
795
795
  }
796
796
 
797
+ /** Builtin studies whose lookback the Indicators dialog lets you change. */
798
+ const PERIOD_STUDIES = new Set<BuiltinStudySelection>(["sma20", "sma50", "sma200", "ema20", "bollinger20", "rsi14"]);
799
+
800
+ export const STUDY_PERIOD_MIN = 2;
801
+ export const STUDY_PERIOD_MAX = 500;
802
+
803
+ export function isPeriodStudy(selection: string): selection is BuiltinStudySelection {
804
+ return PERIOD_STUDIES.has(selection as BuiltinStudySelection);
805
+ }
806
+
807
+ function builtinStudy(spec: ChartSpec, selection: BuiltinStudySelection): ChartStudySpec | undefined {
808
+ const prefix = `${BUILTIN_STUDY_ID_PREFIX}${selection}:`;
809
+ return spec.studies.find((study) => study.id.startsWith(prefix));
810
+ }
811
+
812
+ export function defaultStudyPeriod(selection: BuiltinStudySelection): number | null {
813
+ const defaults = STUDY_DEFAULTS[selection].parameters as Record<string, unknown>;
814
+ return typeof defaults.period === "number" ? defaults.period : null;
815
+ }
816
+
817
+ /** The period a builtin study runs with: its own when selected, the default otherwise. */
818
+ export function builtinStudyPeriod(spec: ChartSpec, selection: BuiltinStudySelection): number | null {
819
+ const period = builtinStudy(spec, selection)?.parameters.period;
820
+ return typeof period === "number" && Number.isFinite(period) ? period : defaultStudyPeriod(selection);
821
+ }
822
+
823
+ /**
824
+ * Changes the period of a selected builtin study. The study keeps its id, so
825
+ * `setBuiltinStudies` carries the new period through later toggles; an
826
+ * unselected study is left alone.
827
+ */
828
+ export function setBuiltinStudyPeriod(
829
+ spec: ChartSpec,
830
+ selection: BuiltinStudySelection,
831
+ period: number,
832
+ ): ChartSpec {
833
+ if (!isPeriodStudy(selection)) return spec;
834
+ const bounded = Math.round(Math.min(STUDY_PERIOD_MAX, Math.max(STUDY_PERIOD_MIN, period)));
835
+ const target = builtinStudy(spec, selection);
836
+ if (!target) return spec;
837
+ return {
838
+ ...spec,
839
+ studies: spec.studies.map((study) => (
840
+ study.id === target.id ? { ...study, parameters: { ...study.parameters, period: bounded } } : study
841
+ )),
842
+ };
843
+ }
844
+
797
845
  export type PairStudySelection = "ratio" | "spread" | "correlation";
798
846
 
799
847
  const PAIR_STUDY_ID_PREFIX = "pair:";
@@ -24,7 +24,10 @@ import {
24
24
  chartSeriesLabel,
25
25
  formatSeriesExpression,
26
26
  getCompatibleSeriesStyles,
27
+ builtinStudyPeriod,
28
+ defaultStudyPeriod,
27
29
  getSelectedBuiltinStudies,
30
+ isPeriodStudy,
28
31
  getSelectedPairStudies,
29
32
  setBuiltinStudies,
30
33
  setPairStudies,
@@ -38,17 +41,45 @@ import {
38
41
 
39
42
  export { CHART_RANGES, CHART_RESOLUTIONS };
40
43
 
41
- export const CHART_STUDY_OPTIONS: Array<PaneSettingOption & { value: BuiltinStudySelection }> = [
42
- { value: "volume", label: "Volume", description: "Volume columns in a lower panel." },
43
- { value: "sma20", label: "SMA 20", description: "20-bar simple moving average on the primary price series." },
44
- { value: "sma50", label: "SMA 50", description: "50-bar simple moving average on the primary price series." },
45
- { value: "sma200", label: "SMA 200", description: "200-bar simple moving average on the primary price series." },
46
- { value: "ema20", label: "EMA 20", description: "20-bar exponential moving average on the primary price series." },
47
- { value: "bollinger20", label: "Bollinger 20", description: "20-bar Bollinger Bands at two standard deviations." },
48
- { value: "rsi14", label: "RSI 14", description: "14-bar Relative Strength Index in a lower panel." },
49
- { value: "macd", label: "MACD", description: "12/26/9 MACD in a lower panel." },
50
- { value: "realized-vol", label: "Realized Volatility", description: "Annualized daily volatility in a lower panel." },
51
- ];
44
+ /** Full name and short form for each builtin indicator; the label adds its period. */
45
+ const CHART_STUDY_NAMES: Record<BuiltinStudySelection, { name: string; short?: string; description: string }> = {
46
+ volume: { name: "Volume", description: "Volume columns in a lower panel." },
47
+ sma20: { name: "Simple moving average", short: "SMA", description: "Simple moving average on the primary price series." },
48
+ sma50: { name: "Simple moving average", short: "SMA", description: "Simple moving average on the primary price series." },
49
+ sma200: { name: "Simple moving average", short: "SMA", description: "Simple moving average on the primary price series." },
50
+ ema20: { name: "Exponential moving average", short: "EMA", description: "Exponential moving average on the primary price series." },
51
+ bollinger20: { name: "Bollinger Bands", short: "BB", description: "Bollinger Bands at two standard deviations." },
52
+ rsi14: { name: "Relative strength index", short: "RSI", description: "Relative Strength Index in a lower panel." },
53
+ macd: { name: "MACD", short: "12, 26, 9", description: "12/26/9 MACD in a lower panel." },
54
+ "realized-vol": { name: "Realized volatility", description: "Annualized daily volatility in a lower panel." },
55
+ };
56
+
57
+ /** Title of the prompt that edits a study's period: `SMA period`. */
58
+ export function chartStudyPeriodTitle(selection: BuiltinStudySelection): string {
59
+ return `${CHART_STUDY_NAMES[selection].short ?? CHART_STUDY_NAMES[selection].name} period`;
60
+ }
61
+
62
+ /** `Simple moving average (SMA 50)`: the name, the acronym and the period it runs with. */
63
+ export function chartStudyLabel(selection: BuiltinStudySelection, period: number | null): string {
64
+ const { name, short } = CHART_STUDY_NAMES[selection];
65
+ if (!short) return name;
66
+ return isPeriodStudy(selection) && period != null ? `${name} (${short} ${period})` : `${name} (${short})`;
67
+ }
68
+
69
+ function chartStudyOptions(periodOf: (selection: BuiltinStudySelection) => number | null) {
70
+ return (Object.keys(CHART_STUDY_NAMES) as BuiltinStudySelection[]).map((value) => ({
71
+ value,
72
+ label: chartStudyLabel(value, periodOf(value)),
73
+ description: CHART_STUDY_NAMES[value].description,
74
+ }));
75
+ }
76
+
77
+ export const CHART_STUDY_OPTIONS: Array<PaneSettingOption & { value: BuiltinStudySelection }> = chartStudyOptions(defaultStudyPeriod);
78
+
79
+ /** The indicator options with the periods this chart actually uses. */
80
+ export function chartStudyOptionsFor(spec: ChartSpec): Array<PaneSettingOption & { value: BuiltinStudySelection }> {
81
+ return chartStudyOptions((selection) => builtinStudyPeriod(spec, selection));
82
+ }
52
83
 
53
84
  export const CHART_FORMULA_OPTIONS: Array<PaneSettingOption & { value: PairStudySelection }> = [
54
85
  { value: "ratio", label: "Ratio", description: "First series divided by the second series." },
@@ -0,0 +1,20 @@
1
+ import { createElement } from "react";
2
+ import { testRender } from "@opentui/react/test-utils";
3
+
4
+ /**
5
+ * A global install runs react-reconciler without its patch (see
6
+ * bin/gloomberb). If the dev build's user timing turns back on, every render
7
+ * adds a performance.measure entry that Bun never frees (#452).
8
+ */
9
+ export async function assertRendersLeaveNoTimingEntries(): Promise<void> {
10
+ // Only the dev reconciler records user timing, and testRender needs the dev
11
+ // build's act; a `--production` binary has neither.
12
+ if (process.env.NODE_ENV === "production") return;
13
+ const setup = await testRender(createElement("text", null, "smoke"), { width: 10, height: 1 });
14
+ await setup.renderOnce();
15
+ const entries = performance.getEntriesByType("measure").length;
16
+ setup.renderer.destroy();
17
+ if (entries > 0) {
18
+ throw new Error(`React recorded ${entries} render timing entries, which are never freed.`);
19
+ }
20
+ }