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 +9 -1
- package/package.json +1 -1
- package/src/cli/native-smoke.ts +2 -0
- package/src/components/ui/index.ts +2 -1
- package/src/components/ui/multi-select/dialog.tsx +46 -4
- package/src/components/ui/number-prompt-dialog.tsx +71 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +48 -3
- package/src/plugins/builtin/chart-composer/presets.test.ts +17 -0
- package/src/plugins/builtin/chart-composer/presets.ts +48 -0
- package/src/plugins/builtin/chart-composer/settings.ts +42 -11
- package/src/renderers/opentui/render-timing-smoke.ts +20 -0
package/bin/gloomberb
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
|
|
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
package/src/cli/native-smoke.ts
CHANGED
|
@@ -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
|
-
|
|
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={
|
|
685
|
+
options={studyOptions}
|
|
646
686
|
selectedValues={selectedStudies}
|
|
647
|
-
onChange={(values) =>
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
{
|
|
45
|
-
{
|
|
46
|
-
{
|
|
47
|
-
{
|
|
48
|
-
{
|
|
49
|
-
{
|
|
50
|
-
{
|
|
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
|
+
}
|