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.
Files changed (51) hide show
  1. package/package.json +6 -2
  2. package/patches/electrobun@1.18.1.patch +85 -0
  3. package/src/app/global-shortcuts.test.tsx +12 -0
  4. package/src/app/global-shortcuts.ts +1 -1
  5. package/src/assets/gloomberb-logo-windows.ico +0 -0
  6. package/src/assets/gloomberb-logo-windows.png +0 -0
  7. package/src/assets/gloomberb-logo.ico +0 -0
  8. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  9. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  10. package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
  11. package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
  12. package/src/components/layout/detached-pane-shell.tsx +11 -4
  13. package/src/components/layout/header.tsx +25 -10
  14. package/src/components/layout/titlebar-overlay.ts +16 -0
  15. package/src/components/layout/window-controls.tsx +99 -0
  16. package/src/components/price-performance.tsx +175 -0
  17. package/src/market-data/performance.test.ts +54 -0
  18. package/src/market-data/performance.ts +109 -0
  19. package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
  20. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  21. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  22. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  23. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  24. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  25. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  26. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  27. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  28. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  29. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  30. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +51 -3
  31. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  32. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  33. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  34. package/src/renderers/electrobun/bun/desktop/window-controls.ts +73 -0
  35. package/src/renderers/electrobun/bun/desktop/window-style.ts +31 -0
  36. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  37. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +70 -0
  38. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  39. package/src/renderers/electrobun/bun/index.ts +36 -7
  40. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  41. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  42. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  43. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  44. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  45. package/src/renderers/electrobun/view/main.tsx +2 -1
  46. package/src/renderers/electrobun/view/styles.css +29 -8
  47. package/src/renderers/electrobun/view/ui-host.tsx +113 -74
  48. package/src/ui/host.tsx +2 -0
  49. package/src/updater.test.ts +47 -2
  50. package/src/updater.ts +67 -10
  51. 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 legend value from the crosshair position", async () => {
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("AAPL $104.00 +4.00%");
347
- expect(frame).toContain("MSFT $204.00 +2.00%");
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).toContain("AAPL $100.00 0.00%");
357
- expect(frame).toContain("MSFT $200.00 0.00%");
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).toContain("AAPL $150.00 +");
410
- expect(frame).toContain("MSFT $250.00 +");
488
+ expect(frame).toMatch(/> AAPL\s+\+47\.06%/);
489
+ expect(frame).toMatch(/MSFT\s+\+23\.76%/);
411
490
  });
412
491
 
413
- test("updates comparison legends from chart-owned quote streams", async () => {
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).toContain("AAPL $155.00 +");
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 () => {