gloomberb 0.9.0 → 0.9.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/package.json +6 -2
- package/patches/electrobun@1.18.1.patch +85 -0
- package/src/app/global-shortcuts.test.tsx +12 -0
- package/src/app/global-shortcuts.ts +1 -1
- package/src/assets/gloomberb-logo-windows.ico +0 -0
- package/src/assets/gloomberb-logo-windows.png +0 -0
- package/src/assets/gloomberb-logo.ico +0 -0
- package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
- package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
- package/src/components/layout/detached-pane-shell.tsx +11 -4
- package/src/components/layout/header.tsx +25 -10
- package/src/components/layout/titlebar-overlay.ts +16 -0
- package/src/components/layout/window-controls.tsx +99 -0
- package/src/components/price-performance.tsx +175 -0
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +51 -3
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +73 -0
- package/src/renderers/electrobun/bun/desktop/window-style.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +70 -0
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +36 -7
- package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
- package/src/renderers/electrobun/bun/window/frame.ts +5 -0
- package/src/renderers/electrobun/shared/protocol.ts +1 -0
- package/src/renderers/electrobun/view/build-assets.ts +2 -1
- package/src/renderers/electrobun/view/host/text.tsx +29 -9
- package/src/renderers/electrobun/view/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +29 -8
- package/src/renderers/electrobun/view/ui-host.tsx +113 -74
- package/src/ui/host.tsx +2 -0
- package/src/updater.test.ts +47 -2
- package/src/updater.ts +67 -10
- package/src/version.ts +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Box, useRendererHost } from "../../ui";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { TITLEBAR_OVERLAY_HEIGHT_PX } from "./titlebar-overlay";
|
|
4
|
+
|
|
5
|
+
const WINDOWS_CONTROL_SIZE_PX = TITLEBAR_OVERLAY_HEIGHT_PX;
|
|
6
|
+
export const WINDOWS_CONTROL_GROUP_WIDTH_PX = WINDOWS_CONTROL_SIZE_PX * 3;
|
|
7
|
+
|
|
8
|
+
type WindowControlAction = "minimize" | "toggle-maximize" | "close";
|
|
9
|
+
|
|
10
|
+
const WINDOWS_CONTROLS: Array<{
|
|
11
|
+
action: WindowControlAction;
|
|
12
|
+
label: string;
|
|
13
|
+
}> = [
|
|
14
|
+
{ action: "minimize", label: "Minimize" },
|
|
15
|
+
{ action: "toggle-maximize", label: "Maximize" },
|
|
16
|
+
{ action: "close", label: "Close" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function stopMouse(event: { stopPropagation?: () => void; preventDefault?: () => void }) {
|
|
20
|
+
event.stopPropagation?.();
|
|
21
|
+
event.preventDefault?.();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function WindowControlIcon({ action }: { action: WindowControlAction }) {
|
|
25
|
+
if (action === "minimize") {
|
|
26
|
+
return (
|
|
27
|
+
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
|
|
28
|
+
<path d="M2.5 6.5H9.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="square" />
|
|
29
|
+
</svg>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (action === "toggle-maximize") {
|
|
34
|
+
return (
|
|
35
|
+
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
|
|
36
|
+
<rect x="3" y="3" width="6" height="6" stroke="currentColor" strokeWidth="1.25" />
|
|
37
|
+
</svg>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
|
|
43
|
+
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface WindowControlsProps {
|
|
49
|
+
windowKind?: "main" | "detached";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function WindowControls({ windowKind = "main" }: WindowControlsProps) {
|
|
53
|
+
const rendererHost = useRendererHost();
|
|
54
|
+
|
|
55
|
+
const controlWindow = useCallback((action: WindowControlAction, event: { stopPropagation?: () => void; preventDefault?: () => void }) => {
|
|
56
|
+
stopMouse(event);
|
|
57
|
+
void rendererHost.controlWindow?.(action);
|
|
58
|
+
}, [rendererHost]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box
|
|
62
|
+
flexDirection="row"
|
|
63
|
+
alignItems="stretch"
|
|
64
|
+
flexShrink={0}
|
|
65
|
+
width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`}
|
|
66
|
+
data-gloom-role="window-controls"
|
|
67
|
+
data-window-kind={windowKind}
|
|
68
|
+
aria-hidden={false}
|
|
69
|
+
style={{
|
|
70
|
+
position: "fixed",
|
|
71
|
+
top: 0,
|
|
72
|
+
right: 0,
|
|
73
|
+
height: `${WINDOWS_CONTROL_SIZE_PX}px`,
|
|
74
|
+
zIndex: 1000,
|
|
75
|
+
backgroundColor: "inherit",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{WINDOWS_CONTROLS.map((control) => (
|
|
79
|
+
<Box
|
|
80
|
+
key={control.action}
|
|
81
|
+
alignItems="center"
|
|
82
|
+
justifyContent="center"
|
|
83
|
+
flexShrink={0}
|
|
84
|
+
width={`${WINDOWS_CONTROL_SIZE_PX}px`}
|
|
85
|
+
height="100%"
|
|
86
|
+
data-gloom-role="window-control"
|
|
87
|
+
data-window-control-action={control.action}
|
|
88
|
+
data-gloom-interactive="true"
|
|
89
|
+
role="button"
|
|
90
|
+
aria-label={control.label}
|
|
91
|
+
title={control.label}
|
|
92
|
+
onMouseDown={(event: { stopPropagation?: () => void; preventDefault?: () => void }) => controlWindow(control.action, event)}
|
|
93
|
+
>
|
|
94
|
+
<WindowControlIcon action={control.action} />
|
|
95
|
+
</Box>
|
|
96
|
+
))}
|
|
97
|
+
</Box>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Box, ScrollBox, Text } from "../ui";
|
|
2
|
+
import { blendHex, colors, priceColor } from "../theme/colors";
|
|
3
|
+
import { displayWidth, formatPercent, padTo } from "../utils/format";
|
|
4
|
+
import type { PriceReturnField } from "../market-data/performance";
|
|
5
|
+
|
|
6
|
+
const STRIP_COLUMN_GAP = 1;
|
|
7
|
+
const RETURN_CELL_MIN_WIDTH = 7;
|
|
8
|
+
const SYMBOL_COLUMN_MIN_WIDTH = 6;
|
|
9
|
+
const SYMBOL_COLUMN_MAX_WIDTH = 12;
|
|
10
|
+
|
|
11
|
+
export interface PriceReturnTableRow {
|
|
12
|
+
symbol: string;
|
|
13
|
+
color: string;
|
|
14
|
+
fields: PriceReturnField[];
|
|
15
|
+
selected: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatReturnValue(value: number | null): string {
|
|
19
|
+
return value == null ? "-" : formatPercent(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function returnValueColor(value: number | null): string {
|
|
23
|
+
return value == null ? colors.textMuted : priceColor(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasAnyReturn(fields: readonly PriceReturnField[]): boolean {
|
|
27
|
+
return fields.some((field) => field.value != null);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function chunkFields<T>(fields: readonly T[], size: number): T[][] {
|
|
31
|
+
const rows: T[][] = [];
|
|
32
|
+
for (let index = 0; index < fields.length; index += size) {
|
|
33
|
+
rows.push(fields.slice(index, index + size));
|
|
34
|
+
}
|
|
35
|
+
return rows;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function PriceReturnStrip({
|
|
39
|
+
fields,
|
|
40
|
+
width,
|
|
41
|
+
}: {
|
|
42
|
+
fields: PriceReturnField[];
|
|
43
|
+
width: number;
|
|
44
|
+
}) {
|
|
45
|
+
if (!hasAnyReturn(fields)) return null;
|
|
46
|
+
|
|
47
|
+
const columnCount = width >= 72
|
|
48
|
+
? Math.min(6, fields.length)
|
|
49
|
+
: width >= 54
|
|
50
|
+
? Math.min(4, fields.length)
|
|
51
|
+
: width >= 36
|
|
52
|
+
? Math.min(3, fields.length)
|
|
53
|
+
: Math.min(2, fields.length);
|
|
54
|
+
const availableWidth = Math.max(width - STRIP_COLUMN_GAP * Math.max(columnCount - 1, 0), columnCount);
|
|
55
|
+
const cellWidth = Math.max(RETURN_CELL_MIN_WIDTH, Math.floor(availableWidth / columnCount));
|
|
56
|
+
const rows = chunkFields(fields, columnCount);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Box flexDirection="column" width={width}>
|
|
60
|
+
{rows.map((row, rowIndex) => (
|
|
61
|
+
<Box key={rowIndex} flexDirection="row" height={1}>
|
|
62
|
+
{row.map((field, columnIndex) => {
|
|
63
|
+
const labelWidth = Math.min(3, Math.max(2, displayWidth(field.label)));
|
|
64
|
+
const valueWidth = Math.max(1, cellWidth - labelWidth);
|
|
65
|
+
return (
|
|
66
|
+
<Box key={field.id} flexDirection="row">
|
|
67
|
+
{columnIndex > 0 && <Box width={STRIP_COLUMN_GAP} />}
|
|
68
|
+
<Box flexDirection="row" width={cellWidth}>
|
|
69
|
+
<Text fg={colors.textDim}>{padTo(field.label, labelWidth)}</Text>
|
|
70
|
+
<Box width={valueWidth} overflow="hidden">
|
|
71
|
+
<Text fg={returnValueColor(field.value)}>{padTo(formatReturnValue(field.value), valueWidth, "right")}</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
</Box>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</Box>
|
|
78
|
+
))}
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getFieldById(fields: readonly PriceReturnField[], id: string): PriceReturnField | null {
|
|
84
|
+
return fields.find((field) => field.id === id) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function chooseComparisonFieldIds(width: number, availableIds: readonly string[]): string[] {
|
|
88
|
+
const preferred = width >= 86
|
|
89
|
+
? ["RNG", "1M", "3M", "6M", "1Y", "3Y", "5Y"]
|
|
90
|
+
: width >= 72
|
|
91
|
+
? ["RNG", "1M", "3M", "1Y", "3Y", "5Y"]
|
|
92
|
+
: width >= 58
|
|
93
|
+
? ["RNG", "1M", "1Y", "3Y", "5Y"]
|
|
94
|
+
: width >= 44
|
|
95
|
+
? ["RNG", "1M", "1Y", "5Y"]
|
|
96
|
+
: ["1Y", "5Y"];
|
|
97
|
+
const available = new Set(availableIds);
|
|
98
|
+
return preferred.filter((id) => available.has(id));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function PriceReturnTable({
|
|
102
|
+
height,
|
|
103
|
+
onOpenSymbol,
|
|
104
|
+
onSelectSymbol,
|
|
105
|
+
rows,
|
|
106
|
+
width,
|
|
107
|
+
}: {
|
|
108
|
+
height: number;
|
|
109
|
+
onOpenSymbol: (symbol: string) => void;
|
|
110
|
+
onSelectSymbol: (symbol: string) => void;
|
|
111
|
+
rows: PriceReturnTableRow[];
|
|
112
|
+
width: number;
|
|
113
|
+
}) {
|
|
114
|
+
if (height <= 0 || rows.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
const availableIds = rows[0]?.fields.map((field) => field.id) ?? [];
|
|
117
|
+
const fieldIds = chooseComparisonFieldIds(width, availableIds);
|
|
118
|
+
if (fieldIds.length === 0) return null;
|
|
119
|
+
|
|
120
|
+
const valueWidth = Math.max(
|
|
121
|
+
RETURN_CELL_MIN_WIDTH,
|
|
122
|
+
Math.floor((width - SYMBOL_COLUMN_MIN_WIDTH - fieldIds.length) / Math.max(fieldIds.length, 1)),
|
|
123
|
+
);
|
|
124
|
+
const symbolWidth = Math.min(
|
|
125
|
+
SYMBOL_COLUMN_MAX_WIDTH,
|
|
126
|
+
Math.max(SYMBOL_COLUMN_MIN_WIDTH, width - fieldIds.length * (valueWidth + 1)),
|
|
127
|
+
);
|
|
128
|
+
const fieldsById = rows[0]?.fields ?? [];
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<ScrollBox height={height} scrollY>
|
|
132
|
+
<Box flexDirection="column" width={width}>
|
|
133
|
+
<Box flexDirection="row" height={1}>
|
|
134
|
+
<Text fg={colors.textDim}>{padTo("Sym", symbolWidth)}</Text>
|
|
135
|
+
{fieldIds.map((fieldId) => {
|
|
136
|
+
const field = getFieldById(fieldsById, fieldId);
|
|
137
|
+
return (
|
|
138
|
+
<Box key={fieldId} flexDirection="row">
|
|
139
|
+
<Text fg={colors.textDim}>{" "}</Text>
|
|
140
|
+
<Text fg={colors.textDim}>{padTo(field?.label ?? fieldId, valueWidth, "right")}</Text>
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</Box>
|
|
145
|
+
|
|
146
|
+
{rows.map((row) => (
|
|
147
|
+
<Box
|
|
148
|
+
key={row.symbol}
|
|
149
|
+
flexDirection="row"
|
|
150
|
+
height={1}
|
|
151
|
+
width={width}
|
|
152
|
+
backgroundColor={row.selected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
|
|
153
|
+
onMouseMove={() => onSelectSymbol(row.symbol)}
|
|
154
|
+
onMouseDown={() => {
|
|
155
|
+
onSelectSymbol(row.symbol);
|
|
156
|
+
onOpenSymbol(row.symbol);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<Text fg={row.color}>{padTo(`${row.selected ? ">" : " "} ${row.symbol}`, symbolWidth)}</Text>
|
|
160
|
+
{fieldIds.map((fieldId) => {
|
|
161
|
+
const field = getFieldById(row.fields, fieldId);
|
|
162
|
+
const value = field?.value ?? null;
|
|
163
|
+
return (
|
|
164
|
+
<Box key={fieldId} flexDirection="row">
|
|
165
|
+
<Text fg={colors.textDim}>{" "}</Text>
|
|
166
|
+
<Text fg={returnValueColor(value)}>{padTo(formatReturnValue(value), valueWidth, "right")}</Text>
|
|
167
|
+
</Box>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</Box>
|
|
171
|
+
))}
|
|
172
|
+
</Box>
|
|
173
|
+
</ScrollBox>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendQuoteToPriceReturnHistory,
|
|
4
|
+
buildPriceReturnFields,
|
|
5
|
+
computePriceReturnForHorizon,
|
|
6
|
+
PRICE_RETURN_HORIZONS,
|
|
7
|
+
} from "./performance";
|
|
8
|
+
import type { PricePoint } from "../types/financials";
|
|
9
|
+
|
|
10
|
+
function point(date: string, close: number): PricePoint {
|
|
11
|
+
return { date: new Date(`${date}T00:00:00Z`), close };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("price performance", () => {
|
|
15
|
+
test("computes horizon return from the closest prior baseline", () => {
|
|
16
|
+
const history = [
|
|
17
|
+
point("2025-12-31", 100),
|
|
18
|
+
point("2026-01-02", 102),
|
|
19
|
+
point("2026-01-31", 110),
|
|
20
|
+
point("2026-02-01", 120),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const oneMonth = PRICE_RETURN_HORIZONS.find((horizon) => horizon.id === "1M")!;
|
|
24
|
+
|
|
25
|
+
expect(computePriceReturnForHorizon(history, oneMonth)).toBeCloseTo(20 / 100);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("leaves fixed horizons empty when history does not reach the baseline date", () => {
|
|
29
|
+
const history = [
|
|
30
|
+
point("2026-01-15", 100),
|
|
31
|
+
point("2026-02-01", 110),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const fields = buildPriceReturnFields(history);
|
|
35
|
+
|
|
36
|
+
expect(fields.find((field) => field.id === "1M")?.value).toBeNull();
|
|
37
|
+
expect(fields.find((field) => field.id === "1Y")?.value).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("can include a newer live quote as the latest return point", () => {
|
|
41
|
+
const history = [
|
|
42
|
+
point("2025-01-31", 100),
|
|
43
|
+
point("2026-01-31", 120),
|
|
44
|
+
];
|
|
45
|
+
const withQuote = appendQuoteToPriceReturnHistory(history, {
|
|
46
|
+
price: 150,
|
|
47
|
+
lastUpdated: Date.parse("2026-02-01T15:30:00Z"),
|
|
48
|
+
});
|
|
49
|
+
const oneYear = PRICE_RETURN_HORIZONS.find((horizon) => horizon.id === "1Y")!;
|
|
50
|
+
|
|
51
|
+
expect(withQuote).toHaveLength(3);
|
|
52
|
+
expect(computePriceReturnForHorizon(withQuote, oneYear)).toBeCloseTo(50 / 100);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { PricePoint, Quote } from "../types/financials";
|
|
2
|
+
|
|
3
|
+
export interface PriceReturnHorizon {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
unit: "month" | "year";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PriceReturnField {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
value: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const PRICE_RETURN_HORIZONS: PriceReturnHorizon[] = [
|
|
17
|
+
{ id: "1M", label: "1M", amount: 1, unit: "month" },
|
|
18
|
+
{ id: "3M", label: "3M", amount: 3, unit: "month" },
|
|
19
|
+
{ id: "6M", label: "6M", amount: 6, unit: "month" },
|
|
20
|
+
{ id: "1Y", label: "1Y", amount: 1, unit: "year" },
|
|
21
|
+
{ id: "3Y", label: "3Y", amount: 3, unit: "year" },
|
|
22
|
+
{ id: "5Y", label: "5Y", amount: 5, unit: "year" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function coercePointDate(value: Date | string | number): Date | null {
|
|
26
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
27
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizePriceReturnHistory(points: readonly PricePoint[]): PricePoint[] {
|
|
31
|
+
const byTimestamp = new Map<number, PricePoint>();
|
|
32
|
+
for (const point of points) {
|
|
33
|
+
const date = coercePointDate(point.date as Date | string | number);
|
|
34
|
+
if (!date || !Number.isFinite(point.close)) continue;
|
|
35
|
+
byTimestamp.set(date.getTime(), { ...point, date });
|
|
36
|
+
}
|
|
37
|
+
return [...byTimestamp.entries()]
|
|
38
|
+
.sort((left, right) => left[0] - right[0])
|
|
39
|
+
.map(([, point]) => point);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function appendQuoteToPriceReturnHistory(
|
|
43
|
+
points: readonly PricePoint[],
|
|
44
|
+
quote: Pick<Quote, "price" | "lastUpdated"> | null | undefined,
|
|
45
|
+
): PricePoint[] {
|
|
46
|
+
const history = normalizePriceReturnHistory(points);
|
|
47
|
+
if (!quote || !Number.isFinite(quote.price) || !Number.isFinite(quote.lastUpdated)) {
|
|
48
|
+
return history;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const quoteDate = new Date(quote.lastUpdated);
|
|
52
|
+
if (!Number.isFinite(quoteDate.getTime())) return history;
|
|
53
|
+
|
|
54
|
+
const latestHistoryTime = history.at(-1)?.date.getTime() ?? Number.NEGATIVE_INFINITY;
|
|
55
|
+
if (quoteDate.getTime() <= latestHistoryTime) return history;
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
...history,
|
|
59
|
+
{
|
|
60
|
+
date: quoteDate,
|
|
61
|
+
close: quote.price,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function subtractHorizon(date: Date, horizon: PriceReturnHorizon): Date {
|
|
67
|
+
const target = new Date(date);
|
|
68
|
+
if (horizon.unit === "month") {
|
|
69
|
+
target.setMonth(target.getMonth() - horizon.amount);
|
|
70
|
+
} else {
|
|
71
|
+
target.setFullYear(target.getFullYear() - horizon.amount);
|
|
72
|
+
}
|
|
73
|
+
return target;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function computePriceReturnForHorizon(
|
|
77
|
+
points: readonly PricePoint[],
|
|
78
|
+
horizon: PriceReturnHorizon,
|
|
79
|
+
): number | null {
|
|
80
|
+
const history = normalizePriceReturnHistory(points);
|
|
81
|
+
if (history.length < 2) return null;
|
|
82
|
+
|
|
83
|
+
const latest = history.at(-1)!;
|
|
84
|
+
const cutoff = subtractHorizon(latest.date, horizon);
|
|
85
|
+
let baseline: PricePoint | null = null;
|
|
86
|
+
|
|
87
|
+
for (const point of history) {
|
|
88
|
+
if (point.date.getTime() <= cutoff.getTime()) {
|
|
89
|
+
baseline = point;
|
|
90
|
+
} else {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!baseline || !Number.isFinite(baseline.close) || baseline.close === 0) return null;
|
|
96
|
+
if (!Number.isFinite(latest.close)) return null;
|
|
97
|
+
return (latest.close - baseline.close) / baseline.close;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildPriceReturnFields(
|
|
101
|
+
points: readonly PricePoint[],
|
|
102
|
+
horizons: readonly PriceReturnHorizon[] = PRICE_RETURN_HORIZONS,
|
|
103
|
+
): PriceReturnField[] {
|
|
104
|
+
return horizons.map((horizon) => ({
|
|
105
|
+
id: horizon.id,
|
|
106
|
+
label: horizon.label,
|
|
107
|
+
value: computePriceReturnForHorizon(points, horizon),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
@@ -68,6 +68,28 @@ function makeFinancials(symbol: string, currency: string, closes: number[]): Tic
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function makeDatedFinancials(symbol: string, currency: string, closes: Array<[string, number]>): TickerFinancials {
|
|
72
|
+
const latest = closes.at(-1);
|
|
73
|
+
const latestClose = latest?.[1] ?? 0;
|
|
74
|
+
const latestDate = latest ? Date.parse(`${latest[0]}T00:00:00Z`) : Date.now();
|
|
75
|
+
return {
|
|
76
|
+
annualStatements: [],
|
|
77
|
+
quarterlyStatements: [],
|
|
78
|
+
quote: {
|
|
79
|
+
symbol,
|
|
80
|
+
price: latestClose,
|
|
81
|
+
currency,
|
|
82
|
+
change: latestClose - (closes[0]?.[1] ?? latestClose),
|
|
83
|
+
changePercent: closes[0]?.[1] ? ((latestClose - closes[0]![1]) / closes[0]![1]) * 100 : 0,
|
|
84
|
+
lastUpdated: latestDate,
|
|
85
|
+
},
|
|
86
|
+
priceHistory: closes.map(([date, close]) => ({
|
|
87
|
+
date: new Date(`${date}T00:00:00Z`),
|
|
88
|
+
close,
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
function createProvider(historyBySymbol: Record<string, number[]>, currencyBySymbol: Record<string, string>): DataProvider {
|
|
72
94
|
return {
|
|
73
95
|
id: "test-provider",
|
|
@@ -101,6 +123,22 @@ function createProvider(historyBySymbol: Record<string, number[]>, currencyBySym
|
|
|
101
123
|
};
|
|
102
124
|
}
|
|
103
125
|
|
|
126
|
+
function createDatedProvider(financialsBySymbol: Record<string, TickerFinancials>): DataProvider {
|
|
127
|
+
const provider = createProvider({}, {});
|
|
128
|
+
provider.getTickerFinancials = async (symbol) => financialsBySymbol[symbol] ?? makeDatedFinancials(symbol, "USD", []);
|
|
129
|
+
provider.getQuote = async (symbol) => financialsBySymbol[symbol]?.quote ?? {
|
|
130
|
+
symbol,
|
|
131
|
+
price: 0,
|
|
132
|
+
currency: "USD",
|
|
133
|
+
change: 0,
|
|
134
|
+
changePercent: 0,
|
|
135
|
+
lastUpdated: Date.now(),
|
|
136
|
+
};
|
|
137
|
+
provider.getPriceHistory = async (symbol) => financialsBySymbol[symbol]?.priceHistory ?? [];
|
|
138
|
+
provider.getPriceHistoryForResolution = async (symbol) => financialsBySymbol[symbol]?.priceHistory ?? [];
|
|
139
|
+
return provider;
|
|
140
|
+
}
|
|
141
|
+
|
|
104
142
|
function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
|
|
105
143
|
return {
|
|
106
144
|
selectTicker: (symbol: string) => { spy.selected.push(symbol); },
|
|
@@ -314,7 +352,7 @@ describe("comparisonChartPlugin", () => {
|
|
|
314
352
|
expect(frame).not.toContain("side by side");
|
|
315
353
|
});
|
|
316
354
|
|
|
317
|
-
test("updates every
|
|
355
|
+
test("updates every return summary range value from the crosshair position", async () => {
|
|
318
356
|
const provider = createProvider({
|
|
319
357
|
AAPL: [100, 102, 104],
|
|
320
358
|
MSFT: [200, 202, 204],
|
|
@@ -343,8 +381,12 @@ describe("comparisonChartPlugin", () => {
|
|
|
343
381
|
|
|
344
382
|
let frame = testSetup!.captureCharFrame();
|
|
345
383
|
expect(frame).not.toContain("AAPL - 1D");
|
|
346
|
-
expect(frame).toContain("
|
|
347
|
-
expect(frame).toContain("
|
|
384
|
+
expect(frame).toContain("Sym");
|
|
385
|
+
expect(frame).toContain("Rng");
|
|
386
|
+
expect(frame).toContain("1Y");
|
|
387
|
+
expect(frame).toContain("5Y");
|
|
388
|
+
expect(frame).toMatch(/> AAPL\s+\+4\.00%/);
|
|
389
|
+
expect(frame).toMatch(/MSFT\s+\+2\.00%/);
|
|
348
390
|
|
|
349
391
|
await act(async () => {
|
|
350
392
|
await testSetup!.mockMouse.moveTo(2, 4);
|
|
@@ -353,8 +395,45 @@ describe("comparisonChartPlugin", () => {
|
|
|
353
395
|
await flushFrames();
|
|
354
396
|
|
|
355
397
|
frame = testSetup!.captureCharFrame();
|
|
356
|
-
expect(frame).
|
|
357
|
-
expect(frame).
|
|
398
|
+
expect(frame).toMatch(/> AAPL\s+0\.00%/);
|
|
399
|
+
expect(frame).toMatch(/MSFT\s+0\.00%/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("sorts comparison summary rows by one-year return", async () => {
|
|
403
|
+
const rows = {
|
|
404
|
+
SLOW: makeDatedFinancials("SLOW", "USD", [["2025-01-02", 100], ["2026-01-02", 105]]),
|
|
405
|
+
FAST: makeDatedFinancials("FAST", "USD", [["2025-01-02", 100], ["2026-01-02", 150]]),
|
|
406
|
+
MID: makeDatedFinancials("MID", "USD", [["2025-01-02", 100], ["2026-01-02", 125]]),
|
|
407
|
+
};
|
|
408
|
+
const provider = createDatedProvider(rows);
|
|
409
|
+
setSharedMarketDataForTests(provider);
|
|
410
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
411
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
412
|
+
setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
|
|
413
|
+
|
|
414
|
+
await mountComparisonHarness({
|
|
415
|
+
axisMode: "percent",
|
|
416
|
+
symbols: ["SLOW", "FAST", "MID"],
|
|
417
|
+
symbolsText: "SLOW, FAST, MID",
|
|
418
|
+
}, [
|
|
419
|
+
makeTicker("SLOW", "USD"),
|
|
420
|
+
makeTicker("FAST", "USD"),
|
|
421
|
+
makeTicker("MID", "USD"),
|
|
422
|
+
], Object.entries(rows));
|
|
423
|
+
|
|
424
|
+
await flushFrames();
|
|
425
|
+
|
|
426
|
+
const frame = testSetup!.captureCharFrame();
|
|
427
|
+
const table = frame.slice(frame.indexOf("Sym"));
|
|
428
|
+
const fastIndex = table.indexOf("FAST");
|
|
429
|
+
const midIndex = table.indexOf("MID");
|
|
430
|
+
const slowIndex = table.indexOf("SLOW");
|
|
431
|
+
|
|
432
|
+
expect(fastIndex).toBeGreaterThanOrEqual(0);
|
|
433
|
+
expect(midIndex).toBeGreaterThanOrEqual(0);
|
|
434
|
+
expect(slowIndex).toBeGreaterThanOrEqual(0);
|
|
435
|
+
expect(fastIndex).toBeLessThan(midIndex);
|
|
436
|
+
expect(midIndex).toBeLessThan(slowIndex);
|
|
358
437
|
});
|
|
359
438
|
|
|
360
439
|
test("extends lagging comparison histories with the latest quote", async () => {
|
|
@@ -406,11 +485,11 @@ describe("comparisonChartPlugin", () => {
|
|
|
406
485
|
await flushFrames();
|
|
407
486
|
|
|
408
487
|
const frame = testSetup!.captureCharFrame();
|
|
409
|
-
expect(frame).
|
|
410
|
-
expect(frame).
|
|
488
|
+
expect(frame).toMatch(/> AAPL\s+\+47\.06%/);
|
|
489
|
+
expect(frame).toMatch(/MSFT\s+\+23\.76%/);
|
|
411
490
|
});
|
|
412
491
|
|
|
413
|
-
test("updates comparison
|
|
492
|
+
test("updates comparison return summary from chart-owned quote streams", async () => {
|
|
414
493
|
let emitQuote: ((symbol: string, price: number) => void) | null = null;
|
|
415
494
|
const provider = createProvider({
|
|
416
495
|
AAPL: [100, 102],
|
|
@@ -464,7 +543,7 @@ describe("comparisonChartPlugin", () => {
|
|
|
464
543
|
await flushFrames();
|
|
465
544
|
|
|
466
545
|
const frame = testSetup!.captureCharFrame();
|
|
467
|
-
expect(frame).
|
|
546
|
+
expect(frame).toMatch(/> AAPL\s+\+51\.96%/);
|
|
468
547
|
});
|
|
469
548
|
|
|
470
549
|
test("does not refetch resolution support when only comparison prices update", async () => {
|