gloomberb 0.9.5 → 0.9.7

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 (99) hide show
  1. package/README.md +49 -8
  2. package/package.json +2 -2
  3. package/src/app/global-shortcuts.test.tsx +20 -1
  4. package/src/app/global-shortcuts.ts +2 -0
  5. package/src/app/pane-runtime/index.tsx +11 -4
  6. package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
  7. package/src/app.tsx +41 -17
  8. package/src/cli/commands/remote.ts +206 -0
  9. package/src/cli/index.ts +2 -0
  10. package/src/components/chart/core/keyboard.ts +10 -1
  11. package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
  12. package/src/components/chart/static/chart/surface.test.tsx +59 -4
  13. package/src/components/chart/static/chart/surface.tsx +4 -0
  14. package/src/components/chart/stock-chart/index.test.ts +7 -0
  15. package/src/components/command-bar/list/view.tsx +47 -0
  16. package/src/components/command-bar/multi-select-picker.tsx +7 -0
  17. package/src/components/command-bar/routing/navigation-state.ts +10 -1
  18. package/src/components/command-bar/surface/index.test.tsx +14 -0
  19. package/src/components/command-bar/theme-picker.tsx +91 -79
  20. package/src/components/command-bar/workflow/field-row.tsx +30 -5
  21. package/src/components/index.ts +7 -0
  22. package/src/components/layout/pane/footer/index.tsx +23 -0
  23. package/src/components/layout/shell/index.test.tsx +2 -0
  24. package/src/components/layout/shell/index.tsx +8 -3
  25. package/src/components/layout/shell/pane/actions.ts +21 -7
  26. package/src/components/metric-treemap/index.tsx +317 -0
  27. package/src/components/metric-treemap/layout.test.ts +77 -0
  28. package/src/components/metric-treemap/layout.ts +476 -0
  29. package/src/components/toggle-list.tsx +10 -0
  30. package/src/components/ui/button.tsx +12 -0
  31. package/src/components/ui/data-table/index.tsx +51 -0
  32. package/src/components/ui/fields.tsx +18 -0
  33. package/src/components/ui/list-view.tsx +83 -3
  34. package/src/components/ui/message-composer.tsx +22 -0
  35. package/src/components/ui/tabs.tsx +45 -0
  36. package/src/core/state/app/layout-reducer.ts +7 -1
  37. package/src/core/state/app/layout.ts +6 -0
  38. package/src/core/state/app/state.ts +23 -1
  39. package/src/core/state/app/types.ts +4 -2
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/chat/channels.ts +5 -18
  42. package/src/plugins/builtin/chat/chat.test.tsx +1 -7
  43. package/src/plugins/builtin/chat/status-widget.tsx +1 -7
  44. package/src/plugins/builtin/cloud/plugin.tsx +4 -13
  45. package/src/plugins/builtin/holders/treemap.tsx +28 -297
  46. package/src/plugins/builtin/holders/types.ts +0 -34
  47. package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
  48. package/src/plugins/builtin/market-heatmap/data.ts +296 -0
  49. package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
  50. package/src/plugins/builtin/plugin-groups.ts +2 -0
  51. package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
  52. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
  53. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  54. package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
  55. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
  57. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
  58. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  59. package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
  60. package/src/plugins/ibkr/gateway/service/index.ts +6 -9
  61. package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
  62. package/src/remote/app-host.tsx +77 -0
  63. package/src/remote/client.ts +34 -0
  64. package/src/remote/command-bar.ts +98 -0
  65. package/src/remote/controller-utils.ts +111 -0
  66. package/src/remote/controller.test.ts +365 -0
  67. package/src/remote/controller.ts +434 -0
  68. package/src/remote/endpoint.ts +41 -0
  69. package/src/remote/json-patch.test.ts +31 -0
  70. package/src/remote/json-patch.ts +89 -0
  71. package/src/remote/layout-helpers.ts +62 -0
  72. package/src/remote/resources.ts +186 -0
  73. package/src/remote/revision.ts +18 -0
  74. package/src/remote/schema.ts +143 -0
  75. package/src/remote/semantic-helpers.ts +161 -0
  76. package/src/remote/semantic-tree.tsx +112 -0
  77. package/src/remote/server.test.ts +50 -0
  78. package/src/remote/server.ts +121 -0
  79. package/src/remote/types.ts +119 -0
  80. package/src/renderers/electrobun/bun/index.ts +55 -1
  81. package/src/renderers/electrobun/shared/protocol.ts +11 -1
  82. package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
  83. package/src/renderers/electrobun/view/build-assets.ts +1 -0
  84. package/src/renderers/electrobun/view/host/box.tsx +5 -0
  85. package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
  86. package/src/renderers/electrobun/view/host/mouse.ts +2 -0
  87. package/src/renderers/electrobun/view/main.tsx +5 -1
  88. package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
  89. package/src/renderers/opentui/chart-surface.test.tsx +101 -0
  90. package/src/renderers/opentui/chart-surface.tsx +15 -20
  91. package/src/renderers/opentui/start.tsx +32 -0
  92. package/src/sources/yahoo-finance/http.ts +31 -4
  93. package/src/state/app/context/index.test.tsx +47 -0
  94. package/src/state/app/context/index.tsx +48 -18
  95. package/src/types/config.ts +1 -0
  96. package/src/ui/host.tsx +1 -0
  97. package/src/ui/index.tsx +183 -7
  98. package/src/version.ts +1 -1
  99. package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
@@ -1,200 +1,14 @@
1
- import { useMemo, useState, type CSSProperties } from "react";
2
- import { Box, Text, TextAttributes, useUiCapabilities } from "../../../ui";
3
- import { blendHex, colors, priceColor } from "../../../theme/colors";
4
- import { blendForContrast, higherContrast } from "../../../theme/color-utils";
5
- import { formatCompact, padTo } from "../../../utils/format";
1
+ import { useMemo } from "react";
2
+ import { MetricTreemapSurface, type MetricTreemapItem } from "../../../components/metric-treemap";
3
+ import { formatCompact } from "../../../utils/format";
6
4
  import {
7
5
  formatHolderOwnershipLine,
8
6
  formatMaybePercent,
9
7
  formatMoneyCompact,
10
8
  } from "./format";
11
- import { buildTreemap, buildTreemapRects } from "./treemap-layout";
12
- import type { FloatTileLayout, HolderRow, PreventableMouseEvent, TileLayout } from "./types";
9
+ import type { HolderRow } from "./types";
13
10
 
14
- function tileColor(row: HolderRow): string {
15
- if (row.changePercent == null) return colors.neutral;
16
- return priceColor(row.changePercent);
17
- }
18
-
19
- function desktopTileColor(row: HolderRow): string {
20
- if (row.changePercent == null || row.changePercent === 0) {
21
- return colors.neutral;
22
- }
23
- const intensity = Math.max(0.38, Math.min(0.88, Math.log1p(Math.abs(row.changePercent)) / Math.log1p(30)));
24
- return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
25
- }
26
-
27
- const TILE_TEXT_MIN_CONTRAST = 4.5;
28
-
29
- function tileTextColor(backgroundColor: string): string {
30
- const preferred = higherContrast(
31
- higherContrast(colors.text, colors.textBright, backgroundColor),
32
- colors.selectedText,
33
- backgroundColor,
34
- );
35
- const fallback = higherContrast("#000000", "#ffffff", backgroundColor);
36
- return blendForContrast(preferred, backgroundColor, fallback, TILE_TEXT_MIN_CONTRAST);
37
- }
38
-
39
- function Tile({ tile, selected, currency, marketCap, onSelect, onActivate }: {
40
- tile: TileLayout;
41
- selected: boolean;
42
- currency: string;
43
- marketCap?: number;
44
- onSelect: () => void;
45
- onActivate?: () => void;
46
- }) {
47
- const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
48
- const renderHeight = Math.max(1, tile.height - (tile.height > 2 ? 1 : 0));
49
- const innerWidth = Math.max(1, renderWidth - 1);
50
- const backgroundColor = selected ? colors.selected : tileColor(tile.row);
51
- const textColor = tileTextColor(backgroundColor);
52
- const attributes = selected ? TextAttributes.BOLD : TextAttributes.NONE;
53
- const label = tile.row.name;
54
- const amount = tile.row.value != null
55
- ? formatMoneyCompact(tile.row.value, currency)
56
- : formatCompact(tile.row.shares);
57
- const ownership = formatHolderOwnershipLine(tile.row, marketCap);
58
- const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
59
-
60
- return (
61
- <Box
62
- position="absolute"
63
- left={tile.x}
64
- top={tile.y}
65
- width={renderWidth}
66
- height={renderHeight}
67
- backgroundColor={backgroundColor}
68
- onMouseDown={(event: PreventableMouseEvent) => {
69
- event.preventDefault();
70
- onSelect();
71
- }}
72
- onDoubleClick={onActivate}
73
- >
74
- <Text fg={textColor} attributes={attributes}>{padTo(label, innerWidth)}</Text>
75
- {renderHeight >= 2 && (
76
- <Text fg={textColor} attributes={attributes}>{padTo(amount, innerWidth)}</Text>
77
- )}
78
- {renderHeight >= 3 && (
79
- <Text fg={textColor} attributes={attributes}>{padTo(ownership ?? change, innerWidth)}</Text>
80
- )}
81
- {ownership && renderHeight >= 4 && (
82
- <Text fg={textColor} attributes={attributes}>{padTo(change, innerWidth)}</Text>
83
- )}
84
- </Box>
85
- );
86
- }
87
-
88
- function pct(value: number, total: number): string {
89
- return `${total > 0 ? value / total * 100 : 0}%`;
90
- }
91
-
92
- function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, marketCap, onSelect, onActivate, onHover }: {
93
- tile: FloatTileLayout;
94
- chartWidth: number;
95
- chartHeight: number;
96
- selected: boolean;
97
- hovered: boolean;
98
- currency: string;
99
- marketCap?: number;
100
- onSelect: () => void;
101
- onActivate?: () => void;
102
- onHover: (hovered: boolean) => void;
103
- }) {
104
- const amount = tile.row.value != null
105
- ? formatMoneyCompact(tile.row.value, currency)
106
- : formatCompact(tile.row.shares);
107
- const ownership = formatHolderOwnershipLine(tile.row, marketCap);
108
- const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
109
- const canShowDetails = tile.width >= 7 && tile.height >= 3;
110
- const canShowChange = tile.height >= 3.7;
111
- const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
112
- const isTiny = tile.width < 5 || tile.height < 2;
113
- const backgroundColor = desktopTileColor(tile.row);
114
- const textColor = tileTextColor(backgroundColor);
115
- const style: CSSProperties = {
116
- position: "absolute",
117
- left: `calc(${pct(tile.x, chartWidth)} + 1px)`,
118
- top: `calc(${pct(tile.y, chartHeight)} + 1px)`,
119
- width: `max(1px, calc(${pct(tile.width, chartWidth)} - 2px))`,
120
- height: `max(1px, calc(${pct(tile.height, chartHeight)} - 2px))`,
121
- minWidth: 1,
122
- minHeight: 1,
123
- padding: isTiny ? "3px 4px" : "7px 8px",
124
- overflow: "hidden",
125
- borderRadius: 5,
126
- border: selected
127
- ? `1px solid ${colors.textBright}`
128
- : hovered
129
- ? `1px solid ${blendHex(colors.textBright, backgroundColor, 0.55)}`
130
- : `1px solid ${blendHex(backgroundColor, colors.textBright, 0.11)}`,
131
- boxShadow: selected
132
- ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.55)}`
133
- : hovered
134
- ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.18)}`
135
- : "none",
136
- backgroundColor,
137
- color: textColor,
138
- display: "flex",
139
- flexDirection: "column",
140
- alignItems: "stretch",
141
- justifyContent: "flex-start",
142
- gap: 2,
143
- cursor: "pointer",
144
- transition: "border-color 120ms ease, box-shadow 120ms ease, filter 120ms ease",
145
- filter: hovered ? "brightness(1.08)" : undefined,
146
- };
147
- const textStyle: CSSProperties = {
148
- display: "block",
149
- minWidth: 0,
150
- width: "100%",
151
- overflow: "hidden",
152
- textOverflow: "ellipsis",
153
- whiteSpace: "nowrap",
154
- lineHeight: 1.1,
155
- letterSpacing: 0,
156
- textShadow: `0 1px 1px ${blendHex(backgroundColor, colors.bg, 0.35)}`,
157
- };
158
-
159
- return (
160
- <Box
161
- data-gloom-role="holders-desktop-tile"
162
- style={style}
163
- onMouseDown={(event: PreventableMouseEvent) => {
164
- event.preventDefault();
165
- onSelect();
166
- }}
167
- onDoubleClick={onActivate}
168
- onMouseOver={() => onHover(true)}
169
- onMouseOut={() => onHover(false)}
170
- >
171
- {canShowLabel && (
172
- <Text
173
- fg={textColor}
174
- attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}
175
- style={{
176
- ...textStyle,
177
- fontSize: isTiny ? 11 : 13,
178
- fontWeight: 700,
179
- }}
180
- >
181
- {tile.row.name}
182
- </Text>
183
- )}
184
- {canShowDetails && (
185
- <>
186
- <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
187
- <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
188
- {ownership && canShowChange && (
189
- <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
190
- )}
191
- </>
192
- )}
193
- </Box>
194
- );
195
- }
196
-
197
- function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, onActivate, currency, marketCap, cellAspect }: {
11
+ export function HoldersTreemap({ rows, width, height, selectedId, onSelect, onActivate, currency, marketCap }: {
198
12
  rows: HolderRow[];
199
13
  width: number;
200
14
  height: number;
@@ -203,116 +17,33 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, onAc
203
17
  onActivate?: (row: HolderRow) => void;
204
18
  currency: string;
205
19
  marketCap?: number;
206
- cellAspect: number;
207
20
  }) {
208
- const [hoveredId, setHoveredId] = useState<string | null>(null);
209
- const chartWidth = Math.max(1, width - 2);
210
- const tiles = useMemo(() => buildTreemapRects(rows, chartWidth, height, cellAspect), [cellAspect, chartWidth, height, rows]);
211
-
212
- if (tiles.length === 0) {
213
- return (
214
- <Box width={width} height={height} paddingX={1} paddingY={1}>
215
- <Text fg={colors.textDim}>No chartable holder values</Text>
216
- </Box>
217
- );
218
- }
21
+ const items = useMemo<Array<MetricTreemapItem<HolderRow>>>(() => rows.map((row) => {
22
+ const ownership = formatHolderOwnershipLine(row, marketCap);
23
+ const change = row.changePercent != null ? formatMaybePercent(row.changePercent) : "No change";
24
+ return {
25
+ id: row.id,
26
+ label: row.name,
27
+ weight: row.value ?? row.shares ?? 0,
28
+ colorValue: row.changePercent ?? null,
29
+ primaryText: row.value != null
30
+ ? formatMoneyCompact(row.value, currency)
31
+ : formatCompact(row.shares),
32
+ secondaryText: ownership ?? change,
33
+ tertiaryText: ownership ? change : null,
34
+ data: row,
35
+ };
36
+ }), [currency, marketCap, rows]);
219
37
 
220
38
  return (
221
- <Box
39
+ <MetricTreemapSurface
40
+ items={items}
222
41
  width={width}
223
42
  height={height}
224
- paddingX={1}
225
- style={{
226
- backgroundColor: colors.bg,
227
- overflow: "hidden",
228
- }}
229
- >
230
- <Box
231
- data-gloom-role="holders-desktop-treemap"
232
- width={chartWidth}
233
- height={height}
234
- style={{
235
- position: "relative",
236
- width: "100%",
237
- height: "100%",
238
- backgroundColor: colors.bg,
239
- overflow: "hidden",
240
- }}
241
- >
242
- {tiles.map((tile) => (
243
- <DesktopTile
244
- key={tile.row.id}
245
- tile={tile}
246
- chartWidth={chartWidth}
247
- chartHeight={height}
248
- selected={tile.row.id === selectedId}
249
- hovered={tile.row.id === hoveredId}
250
- currency={currency}
251
- marketCap={marketCap}
252
- onSelect={() => onSelect(tile.row)}
253
- onActivate={onActivate ? () => onActivate(tile.row) : undefined}
254
- onHover={(isHovered) => setHoveredId((current) => (isHovered ? tile.row.id : current === tile.row.id ? null : current))}
255
- />
256
- ))}
257
- </Box>
258
- </Box>
259
- );
260
- }
261
-
262
- export function HoldersTreemap({ rows, width, height, selectedId, onSelect, onActivate, currency, marketCap }: {
263
- rows: HolderRow[];
264
- width: number;
265
- height: number;
266
- selectedId: string | null;
267
- onSelect: (row: HolderRow) => void;
268
- onActivate?: (row: HolderRow) => void;
269
- currency: string;
270
- marketCap?: number;
271
- }) {
272
- const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
273
- const chartWidth = Math.max(1, width - 2);
274
- const cellAspect = Math.max(0.5, Math.min(4, cellHeightPx / Math.max(1, cellWidthPx)));
275
- const tiles = useMemo(() => buildTreemap(rows, chartWidth, height, cellAspect), [cellAspect, chartWidth, height, rows]);
276
-
277
- if (nativePaneChrome) {
278
- return (
279
- <DesktopHoldersTreemap
280
- rows={rows}
281
- width={width}
282
- height={height}
283
- selectedId={selectedId}
284
- onSelect={onSelect}
285
- onActivate={onActivate}
286
- currency={currency}
287
- marketCap={marketCap}
288
- cellAspect={cellAspect}
289
- />
290
- );
291
- }
292
-
293
- if (tiles.length === 0) {
294
- return (
295
- <Box width={width} height={height} paddingX={1} paddingY={1}>
296
- <Text fg={colors.textDim}>No chartable holder values</Text>
297
- </Box>
298
- );
299
- }
300
-
301
- return (
302
- <Box width={width} height={height} paddingX={1} backgroundColor={colors.bg}>
303
- <Box position="relative" width={chartWidth} height={height} backgroundColor={colors.bg}>
304
- {tiles.map((tile) => (
305
- <Tile
306
- key={tile.row.id}
307
- tile={tile}
308
- selected={tile.row.id === selectedId}
309
- currency={currency}
310
- marketCap={marketCap}
311
- onSelect={() => onSelect(tile.row)}
312
- onActivate={onActivate ? () => onActivate(tile.row) : undefined}
313
- />
314
- ))}
315
- </Box>
316
- </Box>
43
+ selectedId={selectedId}
44
+ onSelect={(item) => onSelect(item.data)}
45
+ onActivate={onActivate ? (item) => onActivate(item.data) : undefined}
46
+ emptyStateTitle="No chartable holder values"
47
+ />
317
48
  );
318
49
  }
@@ -15,37 +15,3 @@ export interface SortPreference {
15
15
  export interface HolderRow extends HolderRecord {
16
16
  id: string;
17
17
  }
18
-
19
- export interface TileLayout {
20
- row: HolderRow;
21
- x: number;
22
- y: number;
23
- width: number;
24
- height: number;
25
- }
26
-
27
- export interface WeightedTreemapItem {
28
- row: HolderRow;
29
- weight: number;
30
- area: number;
31
- }
32
-
33
- export interface TreemapGroup {
34
- items: WeightedTreemapItem[];
35
- weight: number;
36
- }
37
-
38
- export interface FloatRect {
39
- x: number;
40
- y: number;
41
- width: number;
42
- height: number;
43
- }
44
-
45
- export interface FloatTileLayout {
46
- row: HolderRow;
47
- x: number;
48
- y: number;
49
- width: number;
50
- height: number;
51
- }
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ fetchMarketHeatmap,
4
+ parseNasdaqMarketHeatmapResponse,
5
+ parseYahooMarketHeatmapResponse,
6
+ resetMarketHeatmapCache,
7
+ } from "./data";
8
+
9
+ afterEach(() => {
10
+ resetMarketHeatmapCache();
11
+ });
12
+
13
+ describe("market heatmap data", () => {
14
+ test("parses Yahoo equity screener rows by market cap", () => {
15
+ const rows = parseYahooMarketHeatmapResponse({
16
+ finance: {
17
+ result: [{
18
+ quotes: [{
19
+ symbol: "NVDA",
20
+ shortName: "NVIDIA",
21
+ regularMarketPrice: 143,
22
+ regularMarketChange: 2.5,
23
+ regularMarketChangePercent: 1.78,
24
+ marketCap: 3_500_000_000_000,
25
+ regularMarketVolume: 45_000_000,
26
+ currency: "USD",
27
+ fullExchangeName: "NasdaqGS",
28
+ sector: "Technology",
29
+ industry: "Semiconductors",
30
+ marketState: "REGULAR",
31
+ }],
32
+ }],
33
+ },
34
+ }, "us-equity");
35
+
36
+ expect(rows).toEqual([{
37
+ symbol: "NVDA",
38
+ name: "NVIDIA",
39
+ price: 143,
40
+ change: 2.5,
41
+ changePercent: 1.78,
42
+ size: 3_500_000_000_000,
43
+ sizeKind: "market-cap",
44
+ volume: 45_000_000,
45
+ currency: "USD",
46
+ exchange: "NasdaqGS",
47
+ sector: "Technology",
48
+ industry: "Semiconductors",
49
+ marketState: "REGULAR",
50
+ source: "yahoo",
51
+ }]);
52
+ });
53
+
54
+ test("parses Yahoo ETF screener rows by net assets", () => {
55
+ const rows = parseYahooMarketHeatmapResponse({
56
+ finance: {
57
+ result: [{
58
+ quotes: [{
59
+ symbol: "VOO",
60
+ longName: "Vanguard S&P 500 ETF",
61
+ regularMarketPrice: 686.1,
62
+ regularMarketChangePercent: -0.21,
63
+ netAssets: 1_700_000_000_000,
64
+ regularMarketVolume: 5_500_000,
65
+ currency: "USD",
66
+ }],
67
+ }],
68
+ },
69
+ }, "us-etf");
70
+
71
+ expect(rows[0]).toMatchObject({
72
+ symbol: "VOO",
73
+ name: "Vanguard S&P 500 ETF",
74
+ size: 1_700_000_000_000,
75
+ sizeKind: "net-assets",
76
+ changePercent: -0.21,
77
+ source: "yahoo",
78
+ });
79
+ });
80
+
81
+ test("parses and sorts Nasdaq fallback rows by market cap", () => {
82
+ const rows = parseNasdaqMarketHeatmapResponse({
83
+ data: {
84
+ rows: [
85
+ { symbol: "SMALL", name: "Small Co", lastsale: "$10.00", netchange: "0.10", pctchange: "1.00%", marketCap: "1000000", volume: "1000" },
86
+ { symbol: "BIG", name: "Big Co", lastsale: "$250.00", netchange: "-5.00", pctchange: "-2.00%", marketCap: "3000000000", volume: "100000" },
87
+ ],
88
+ },
89
+ });
90
+
91
+ expect(rows.map((row) => row.symbol)).toEqual(["BIG", "SMALL"]);
92
+ expect(rows[0]).toMatchObject({
93
+ symbol: "BIG",
94
+ price: 250,
95
+ change: -5,
96
+ changePercent: -2,
97
+ size: 3_000_000_000,
98
+ source: "nasdaq",
99
+ });
100
+ });
101
+
102
+ test("falls back to Nasdaq for US equities when Yahoo returns no assets", async () => {
103
+ const result = await fetchMarketHeatmap("us-equity", { count: 1, cache: false }, {
104
+ yahooClient: {
105
+ async postJsonWithCrumb<T>() {
106
+ return { finance: { result: [{ quotes: [] }] } } as T;
107
+ },
108
+ },
109
+ nasdaqFetch: async () => new Response(JSON.stringify({
110
+ data: {
111
+ rows: [
112
+ { symbol: "AAPL", name: "Apple", lastsale: "$200.00", netchange: "1.00", pctchange: "0.50%", marketCap: "3000000000000", volume: "50000000" },
113
+ ],
114
+ },
115
+ })),
116
+ });
117
+
118
+ expect(result.source).toBe("nasdaq");
119
+ expect(result.assets.map((asset) => asset.symbol)).toEqual(["AAPL"]);
120
+ });
121
+ });