gloomberb 0.7.0 → 0.7.1
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 +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +63 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +6 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
|
|
2
|
+
import { Box, ScrollBox, Text, TextAttributes, useUiHost } from "../../../ui";
|
|
3
3
|
import { useShortcut } from "../../../react/input";
|
|
4
4
|
import { SpeedometerGauge, StaticChartSurface, usePaneFooter, type SpeedometerSegment } from "../../../components";
|
|
5
5
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
} from "./fear-greed-data";
|
|
17
17
|
|
|
18
18
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
19
|
+
const DESKTOP_SUMMARY_STACK_WIDTH = 84;
|
|
20
|
+
const CHART_META_STACK_WIDTH = 84;
|
|
19
21
|
const FEAR_GREED_GAUGE_SEGMENTS: SpeedometerSegment[] = [
|
|
20
22
|
{ from: 0, to: 24.999, label: "EXTREME FEAR", color: colors.negative },
|
|
21
23
|
{ from: 25, to: 44.999, label: "FEAR", color: colors.warning },
|
|
@@ -134,19 +136,37 @@ function SentimentBadge({ rating }: { rating: FearGreedRating }) {
|
|
|
134
136
|
);
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
function PreviousScoreGrid({ data, width }: { data: FearGreedData; width: number }) {
|
|
139
|
+
function PreviousScoreGrid({ data, width, layout = "grid" }: { data: FearGreedData; width: number; layout?: "grid" | "rail" }) {
|
|
138
140
|
const items = [
|
|
139
141
|
{ label: "Previous close", value: data.overall.previousClose },
|
|
140
142
|
{ label: "1 week ago", value: data.overall.previousWeek },
|
|
141
143
|
{ label: "1 month ago", value: data.overall.previousMonth },
|
|
142
144
|
{ label: "1 year ago", value: data.overall.previousYear },
|
|
143
145
|
];
|
|
144
|
-
const columns = width >= 84 ? 4 : 2;
|
|
146
|
+
const columns = width >= 84 ? 4 : width >= 42 ? 2 : 1;
|
|
145
147
|
const columnWidth = Math.max(18, Math.floor((width - 2) / columns));
|
|
146
148
|
const rows = Array.from({ length: Math.ceil(items.length / columns) }, (_, rowIndex) => (
|
|
147
149
|
items.slice(rowIndex * columns, rowIndex * columns + columns)
|
|
148
150
|
));
|
|
149
151
|
|
|
152
|
+
if (layout === "rail") {
|
|
153
|
+
return (
|
|
154
|
+
<Box flexDirection="column" width={width} flexShrink={0}>
|
|
155
|
+
{items.map((item) => {
|
|
156
|
+
const value = item.value;
|
|
157
|
+
const color = value == null ? colors.textDim : ratingColor(value < 25 ? "extreme fear" : value < 45 ? "fear" : value <= 55 ? "neutral" : "greed");
|
|
158
|
+
return (
|
|
159
|
+
<Box key={item.label} flexDirection="row" height={1}>
|
|
160
|
+
<Text fg={colors.textDim}>{item.label}: </Text>
|
|
161
|
+
<Box flexGrow={1} />
|
|
162
|
+
<Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(value)}</Text>
|
|
163
|
+
</Box>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</Box>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
150
170
|
return (
|
|
151
171
|
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
|
152
172
|
{rows.map((row, rowIndex) => (
|
|
@@ -184,7 +204,6 @@ function chartOverlay(indicator: FearGreedIndicator): ChartIndicatorOverlays | n
|
|
|
184
204
|
|
|
185
205
|
function SentimentChart({
|
|
186
206
|
title,
|
|
187
|
-
subtitle,
|
|
188
207
|
rating,
|
|
189
208
|
score,
|
|
190
209
|
points,
|
|
@@ -197,7 +216,6 @@ function SentimentChart({
|
|
|
197
216
|
overlays,
|
|
198
217
|
}: {
|
|
199
218
|
title: string;
|
|
200
|
-
subtitle: string;
|
|
201
219
|
rating: FearGreedRating;
|
|
202
220
|
score: number | null;
|
|
203
221
|
points: FearGreedIndicator["points"];
|
|
@@ -209,6 +227,8 @@ function SentimentChart({
|
|
|
209
227
|
secondaryValue?: number | null;
|
|
210
228
|
overlays?: ChartIndicatorOverlays | null;
|
|
211
229
|
}) {
|
|
230
|
+
const isDesktopWeb = useUiHost().kind === "desktop-web";
|
|
231
|
+
const stackMeta = width < CHART_META_STACK_WIDTH;
|
|
212
232
|
const chartWidth = Math.max(24, width - 2);
|
|
213
233
|
const chartHeight = width >= 96 ? 12 : 10;
|
|
214
234
|
const color = ratingColor(rating);
|
|
@@ -222,36 +242,42 @@ function SentimentChart({
|
|
|
222
242
|
const latest = points.length > 0 ? points[points.length - 1]!.close : null;
|
|
223
243
|
|
|
224
244
|
return (
|
|
225
|
-
<Box flexDirection="column" marginTop={2} paddingX={1}>
|
|
245
|
+
<Box flexDirection="column" marginTop={isDesktopWeb ? 1 : 2} paddingX={1}>
|
|
226
246
|
<Box flexDirection="row" height={1}>
|
|
227
247
|
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{title.toUpperCase()}</Text>
|
|
228
248
|
<Box flexGrow={1} />
|
|
229
249
|
<SentimentBadge rating={rating} />
|
|
230
250
|
</Box>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
{stackMeta ? (
|
|
252
|
+
<>
|
|
253
|
+
<Box flexDirection="row" height={1} overflow="hidden">
|
|
254
|
+
<SeriesLegend color={color} primaryLabel={primaryLabel} secondaryLabel={secondaryLabel} />
|
|
255
|
+
</Box>
|
|
256
|
+
<Box flexDirection="row" height={1} overflow="hidden">
|
|
257
|
+
<ChartStats
|
|
258
|
+
color={color}
|
|
259
|
+
latest={latest}
|
|
260
|
+
score={score}
|
|
261
|
+
secondaryLabel={secondaryLabel}
|
|
262
|
+
secondaryValue={secondaryValue}
|
|
263
|
+
valueFormat={valueFormat}
|
|
264
|
+
/>
|
|
265
|
+
</Box>
|
|
266
|
+
</>
|
|
267
|
+
) : (
|
|
268
|
+
<Box flexDirection="row" height={1} overflow="hidden">
|
|
269
|
+
<SeriesLegend color={color} primaryLabel={primaryLabel} secondaryLabel={secondaryLabel} />
|
|
270
|
+
<Box flexGrow={1} />
|
|
271
|
+
<ChartStats
|
|
272
|
+
color={color}
|
|
273
|
+
latest={latest}
|
|
274
|
+
score={score}
|
|
275
|
+
secondaryLabel={secondaryLabel}
|
|
276
|
+
secondaryValue={secondaryValue}
|
|
277
|
+
valueFormat={valueFormat}
|
|
278
|
+
/>
|
|
279
|
+
</Box>
|
|
280
|
+
)}
|
|
255
281
|
{points.length >= 2 ? (
|
|
256
282
|
<Box marginTop={1}>
|
|
257
283
|
<StaticChartSurface
|
|
@@ -279,6 +305,60 @@ function SentimentChart({
|
|
|
279
305
|
);
|
|
280
306
|
}
|
|
281
307
|
|
|
308
|
+
function SeriesLegend({
|
|
309
|
+
color,
|
|
310
|
+
primaryLabel,
|
|
311
|
+
secondaryLabel,
|
|
312
|
+
}: {
|
|
313
|
+
color: string;
|
|
314
|
+
primaryLabel: string;
|
|
315
|
+
secondaryLabel?: string;
|
|
316
|
+
}) {
|
|
317
|
+
return (
|
|
318
|
+
<>
|
|
319
|
+
<Text fg={color}>● </Text>
|
|
320
|
+
<Text fg={colors.textDim}>{primaryLabel}</Text>
|
|
321
|
+
{secondaryLabel ? (
|
|
322
|
+
<>
|
|
323
|
+
<Text fg={colors.warning}> ● </Text>
|
|
324
|
+
<Text fg={colors.textDim}>{secondaryLabel}</Text>
|
|
325
|
+
</>
|
|
326
|
+
) : null}
|
|
327
|
+
</>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ChartStats({
|
|
332
|
+
color,
|
|
333
|
+
latest,
|
|
334
|
+
score,
|
|
335
|
+
secondaryLabel,
|
|
336
|
+
secondaryValue,
|
|
337
|
+
valueFormat,
|
|
338
|
+
}: {
|
|
339
|
+
color: string;
|
|
340
|
+
latest: number | null;
|
|
341
|
+
score: number | null;
|
|
342
|
+
secondaryLabel?: string;
|
|
343
|
+
secondaryValue?: number | null;
|
|
344
|
+
valueFormat: FearGreedValueFormat;
|
|
345
|
+
}) {
|
|
346
|
+
return (
|
|
347
|
+
<>
|
|
348
|
+
<Text fg={colors.textDim}>score </Text>
|
|
349
|
+
<Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(score)}</Text>
|
|
350
|
+
<Text fg={colors.textDim}> latest </Text>
|
|
351
|
+
<Text fg={colors.text}>{formatIndicatorValue(latest, valueFormat)}</Text>
|
|
352
|
+
{secondaryLabel && secondaryValue != null ? (
|
|
353
|
+
<>
|
|
354
|
+
<Text fg={colors.textDim}> avg </Text>
|
|
355
|
+
<Text fg={colors.text}>{formatIndicatorValue(secondaryValue, valueFormat)}</Text>
|
|
356
|
+
</>
|
|
357
|
+
) : null}
|
|
358
|
+
</>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
282
362
|
function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number }) {
|
|
283
363
|
const indicator: FearGreedIndicator = {
|
|
284
364
|
definition: {
|
|
@@ -301,7 +381,6 @@ function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number
|
|
|
301
381
|
return (
|
|
302
382
|
<SentimentChart
|
|
303
383
|
title={indicator.definition.title}
|
|
304
|
-
subtitle={indicator.definition.subtitle}
|
|
305
384
|
rating={indicator.rating}
|
|
306
385
|
score={indicator.score}
|
|
307
386
|
points={indicator.points}
|
|
@@ -314,6 +393,7 @@ function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number
|
|
|
314
393
|
}
|
|
315
394
|
|
|
316
395
|
function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
|
|
396
|
+
const isDesktopWeb = useUiHost().kind === "desktop-web";
|
|
317
397
|
const [data, setData] = useState<FearGreedData | null>(sharedCache?.data ?? null);
|
|
318
398
|
const [loading, setLoading] = useState(!sharedCache);
|
|
319
399
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -354,6 +434,11 @@ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
|
|
|
354
434
|
void load(true);
|
|
355
435
|
}, [load]);
|
|
356
436
|
|
|
437
|
+
const stackDesktopSummary = isDesktopWeb && width < DESKTOP_SUMMARY_STACK_WIDTH;
|
|
438
|
+
const desktopSummaryRailWidth = stackDesktopSummary ? Math.max(18, Math.min(width - 2, 42)) : 26;
|
|
439
|
+
const desktopSummaryGaugeMaxWidth = Math.max(1, Math.min(width - 2, 50));
|
|
440
|
+
const desktopSummaryGaugeMinWidth = Math.min(34, desktopSummaryGaugeMaxWidth);
|
|
441
|
+
|
|
357
442
|
useShortcut((event) => {
|
|
358
443
|
if (!focused) return;
|
|
359
444
|
if (event.name === "r") {
|
|
@@ -402,18 +487,43 @@ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
|
|
|
402
487
|
<Box flexDirection="column" width={width} height={height}>
|
|
403
488
|
<ScrollBox flexGrow={1} scrollY focusable={false}>
|
|
404
489
|
<Box flexDirection="column" paddingBottom={1}>
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
490
|
+
{isDesktopWeb ? (
|
|
491
|
+
<Box
|
|
492
|
+
flexDirection={stackDesktopSummary ? "column" : "row"}
|
|
493
|
+
alignItems="center"
|
|
494
|
+
justifyContent="center"
|
|
495
|
+
gap={stackDesktopSummary ? 0 : 4}
|
|
496
|
+
paddingX={1}
|
|
497
|
+
>
|
|
498
|
+
<SpeedometerGauge
|
|
499
|
+
value={data.overall.score}
|
|
500
|
+
valueLabel={ratingLabel(data.overall.rating)}
|
|
501
|
+
width={width}
|
|
502
|
+
segments={FEAR_GREED_GAUGE_SEGMENTS}
|
|
503
|
+
minWidth={stackDesktopSummary ? desktopSummaryGaugeMinWidth : undefined}
|
|
504
|
+
maxWidth={stackDesktopSummary ? desktopSummaryGaugeMaxWidth : undefined}
|
|
505
|
+
compact={stackDesktopSummary}
|
|
506
|
+
/>
|
|
507
|
+
<Box marginTop={0}>
|
|
508
|
+
<PreviousScoreGrid data={data} width={desktopSummaryRailWidth} layout="rail" />
|
|
509
|
+
</Box>
|
|
510
|
+
</Box>
|
|
511
|
+
) : (
|
|
512
|
+
<>
|
|
513
|
+
<SpeedometerGauge
|
|
514
|
+
value={data.overall.score}
|
|
515
|
+
valueLabel={ratingLabel(data.overall.rating)}
|
|
516
|
+
width={width}
|
|
517
|
+
segments={FEAR_GREED_GAUGE_SEGMENTS}
|
|
518
|
+
/>
|
|
519
|
+
<PreviousScoreGrid data={data} width={width} />
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
411
522
|
{loading ? (
|
|
412
523
|
<Box height={1} paddingX={1} marginTop={1} justifyContent="center">
|
|
413
524
|
<Text fg={colors.textMuted}>refreshing...</Text>
|
|
414
525
|
</Box>
|
|
415
526
|
) : null}
|
|
416
|
-
<PreviousScoreGrid data={data} width={width} />
|
|
417
527
|
{error ? (
|
|
418
528
|
<Box paddingX={1} marginTop={1}>
|
|
419
529
|
<Text fg={colors.warning}>{error}</Text>
|
|
@@ -427,7 +537,6 @@ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
|
|
|
427
537
|
<SentimentChart
|
|
428
538
|
key={indicator.definition.id}
|
|
429
539
|
title={indicator.definition.title}
|
|
430
|
-
subtitle={indicator.definition.subtitle}
|
|
431
540
|
rating={indicator.rating}
|
|
432
541
|
score={indicator.score}
|
|
433
542
|
points={indicator.points}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildTreemap,
|
|
4
|
+
buildTreemapRects,
|
|
5
|
+
formatHolderOwnershipPercent,
|
|
6
|
+
resolveHolderOwnershipPercent,
|
|
7
|
+
} from "./index";
|
|
3
8
|
import type { HolderRecord } from "../../../types/financials";
|
|
4
9
|
|
|
5
10
|
function holder(name: string, value: number): HolderRecord & { id: string } {
|
|
@@ -43,3 +48,10 @@ test("groups large holders using terminal cell aspect instead of full-height str
|
|
|
43
48
|
expect(rects[1]!.height).toBeLessThan(40);
|
|
44
49
|
expect(rects[0]!.width).toBeCloseTo(rects[1]!.width, 8);
|
|
45
50
|
});
|
|
51
|
+
|
|
52
|
+
test("resolves holder ownership from provider percent before value over market cap", () => {
|
|
53
|
+
expect(resolveHolderOwnershipPercent({ percentHeld: 0.085, value: 120 }, 1_000)).toBe(0.085);
|
|
54
|
+
expect(resolveHolderOwnershipPercent({ value: 120 }, 1_000)).toBe(0.12);
|
|
55
|
+
expect(resolveHolderOwnershipPercent({ value: 120 }, undefined)).toBeUndefined();
|
|
56
|
+
expect(formatHolderOwnershipPercent(0.085)).toBe("8.50%");
|
|
57
|
+
});
|
|
@@ -90,6 +90,23 @@ function formatMaybePercent(value: number | undefined): string {
|
|
|
90
90
|
return Math.abs(value) <= 1 ? formatPercent(value) : formatPercentRaw(value);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export function resolveHolderOwnershipPercent(row: Pick<HolderRecord, "percentHeld" | "value">, marketCap: number | undefined): number | undefined {
|
|
94
|
+
if (row.percentHeld != null) return row.percentHeld;
|
|
95
|
+
if (row.value == null || row.value < 0 || marketCap == null || marketCap <= 0) return undefined;
|
|
96
|
+
return row.value / marketCap;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatHolderOwnershipPercent(value: number | undefined): string {
|
|
100
|
+
if (value == null) return "-";
|
|
101
|
+
const percent = Math.abs(value) <= 1 ? value * 100 : value;
|
|
102
|
+
return `${percent.toFixed(2)}%`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatHolderOwnershipLine(row: HolderRow, marketCap: number | undefined): string | null {
|
|
106
|
+
const ownership = resolveHolderOwnershipPercent(row, marketCap);
|
|
107
|
+
return ownership == null ? null : `${formatHolderOwnershipPercent(ownership)} held`;
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
function formatSignedCompact(value: number | undefined): string {
|
|
94
111
|
if (value == null) return "-";
|
|
95
112
|
const sign = value > 0 ? "+" : "";
|
|
@@ -133,7 +150,7 @@ function buildColumns(width: number): HolderColumn[] {
|
|
|
133
150
|
];
|
|
134
151
|
}
|
|
135
152
|
|
|
136
|
-
function sortValue(row: HolderRow, columnId: HolderColumnId): string | number | null {
|
|
153
|
+
function sortValue(row: HolderRow, columnId: HolderColumnId, marketCap?: number): string | number | null {
|
|
137
154
|
switch (columnId) {
|
|
138
155
|
case "holder":
|
|
139
156
|
return row.name;
|
|
@@ -146,7 +163,7 @@ function sortValue(row: HolderRow, columnId: HolderColumnId): string | number |
|
|
|
146
163
|
case "changePercent":
|
|
147
164
|
return row.changePercent ?? null;
|
|
148
165
|
case "percentHeld":
|
|
149
|
-
return row
|
|
166
|
+
return resolveHolderOwnershipPercent(row, marketCap) ?? null;
|
|
150
167
|
case "reportDate":
|
|
151
168
|
return row.reportDate ?? null;
|
|
152
169
|
}
|
|
@@ -167,10 +184,10 @@ function compareSortValues(
|
|
|
167
184
|
return direction === "asc" ? comparison : -comparison;
|
|
168
185
|
}
|
|
169
186
|
|
|
170
|
-
function sortRows(rows: HolderRow[], preference: SortPreference): HolderRow[] {
|
|
187
|
+
function sortRows(rows: HolderRow[], preference: SortPreference, marketCap?: number): HolderRow[] {
|
|
171
188
|
return [...rows].sort((left, right) => compareSortValues(
|
|
172
|
-
sortValue(left, preference.columnId),
|
|
173
|
-
sortValue(right, preference.columnId),
|
|
189
|
+
sortValue(left, preference.columnId, marketCap),
|
|
190
|
+
sortValue(right, preference.columnId, marketCap),
|
|
174
191
|
preference.direction,
|
|
175
192
|
));
|
|
176
193
|
}
|
|
@@ -428,10 +445,11 @@ function desktopTileColor(row: HolderRow): string {
|
|
|
428
445
|
return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
|
|
429
446
|
}
|
|
430
447
|
|
|
431
|
-
function Tile({ tile, selected, currency, onSelect }: {
|
|
448
|
+
function Tile({ tile, selected, currency, marketCap, onSelect }: {
|
|
432
449
|
tile: TileLayout;
|
|
433
450
|
selected: boolean;
|
|
434
451
|
currency: string;
|
|
452
|
+
marketCap?: number;
|
|
435
453
|
onSelect: () => void;
|
|
436
454
|
}) {
|
|
437
455
|
const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
|
|
@@ -443,6 +461,7 @@ function Tile({ tile, selected, currency, onSelect }: {
|
|
|
443
461
|
const amount = tile.row.value != null
|
|
444
462
|
? formatMoneyCompact(tile.row.value, currency)
|
|
445
463
|
: formatCompact(tile.row.shares);
|
|
464
|
+
const ownership = formatHolderOwnershipLine(tile.row, marketCap);
|
|
446
465
|
const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
|
|
447
466
|
|
|
448
467
|
return (
|
|
@@ -463,6 +482,9 @@ function Tile({ tile, selected, currency, onSelect }: {
|
|
|
463
482
|
<Text fg={textColor} attributes={attributes}>{padTo(amount, innerWidth)}</Text>
|
|
464
483
|
)}
|
|
465
484
|
{renderHeight >= 3 && (
|
|
485
|
+
<Text fg={textColor} attributes={attributes}>{padTo(ownership ?? change, innerWidth)}</Text>
|
|
486
|
+
)}
|
|
487
|
+
{ownership && renderHeight >= 4 && (
|
|
466
488
|
<Text fg={textColor} attributes={attributes}>{padTo(change, innerWidth)}</Text>
|
|
467
489
|
)}
|
|
468
490
|
</Box>
|
|
@@ -473,21 +495,24 @@ function pct(value: number, total: number): string {
|
|
|
473
495
|
return `${total > 0 ? value / total * 100 : 0}%`;
|
|
474
496
|
}
|
|
475
497
|
|
|
476
|
-
function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, onSelect, onHover }: {
|
|
498
|
+
function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, marketCap, onSelect, onHover }: {
|
|
477
499
|
tile: FloatTileLayout;
|
|
478
500
|
chartWidth: number;
|
|
479
501
|
chartHeight: number;
|
|
480
502
|
selected: boolean;
|
|
481
503
|
hovered: boolean;
|
|
482
504
|
currency: string;
|
|
505
|
+
marketCap?: number;
|
|
483
506
|
onSelect: () => void;
|
|
484
507
|
onHover: (hovered: boolean) => void;
|
|
485
508
|
}) {
|
|
486
509
|
const amount = tile.row.value != null
|
|
487
510
|
? formatMoneyCompact(tile.row.value, currency)
|
|
488
511
|
: formatCompact(tile.row.shares);
|
|
512
|
+
const ownership = formatHolderOwnershipLine(tile.row, marketCap);
|
|
489
513
|
const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
|
|
490
514
|
const canShowDetails = tile.width >= 7 && tile.height >= 3;
|
|
515
|
+
const canShowChange = tile.height >= 3.7;
|
|
491
516
|
const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
|
|
492
517
|
const isTiny = tile.width < 5 || tile.height < 2;
|
|
493
518
|
const backgroundColor = desktopTileColor(tile.row);
|
|
@@ -562,20 +587,24 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
|
|
|
562
587
|
{canShowDetails && (
|
|
563
588
|
<>
|
|
564
589
|
<Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
|
|
565
|
-
<Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
|
|
590
|
+
<Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
|
|
591
|
+
{ownership && canShowChange && (
|
|
592
|
+
<Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
|
|
593
|
+
)}
|
|
566
594
|
</>
|
|
567
595
|
)}
|
|
568
596
|
</Box>
|
|
569
597
|
);
|
|
570
598
|
}
|
|
571
599
|
|
|
572
|
-
function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, currency, cellAspect }: {
|
|
600
|
+
function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, currency, marketCap, cellAspect }: {
|
|
573
601
|
rows: HolderRow[];
|
|
574
602
|
width: number;
|
|
575
603
|
height: number;
|
|
576
604
|
selectedId: string | null;
|
|
577
605
|
onSelect: (row: HolderRow) => void;
|
|
578
606
|
currency: string;
|
|
607
|
+
marketCap?: number;
|
|
579
608
|
cellAspect: number;
|
|
580
609
|
}) {
|
|
581
610
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
@@ -621,6 +650,7 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, curr
|
|
|
621
650
|
selected={tile.row.id === selectedId}
|
|
622
651
|
hovered={tile.row.id === hoveredId}
|
|
623
652
|
currency={currency}
|
|
653
|
+
marketCap={marketCap}
|
|
624
654
|
onSelect={() => onSelect(tile.row)}
|
|
625
655
|
onHover={(isHovered) => setHoveredId((current) => (isHovered ? tile.row.id : current === tile.row.id ? null : current))}
|
|
626
656
|
/>
|
|
@@ -630,13 +660,14 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, curr
|
|
|
630
660
|
);
|
|
631
661
|
}
|
|
632
662
|
|
|
633
|
-
function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }: {
|
|
663
|
+
function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency, marketCap }: {
|
|
634
664
|
rows: HolderRow[];
|
|
635
665
|
width: number;
|
|
636
666
|
height: number;
|
|
637
667
|
selectedId: string | null;
|
|
638
668
|
onSelect: (row: HolderRow) => void;
|
|
639
669
|
currency: string;
|
|
670
|
+
marketCap?: number;
|
|
640
671
|
}) {
|
|
641
672
|
const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
|
|
642
673
|
const chartWidth = Math.max(1, width - 2);
|
|
@@ -652,6 +683,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
|
|
|
652
683
|
selectedId={selectedId}
|
|
653
684
|
onSelect={onSelect}
|
|
654
685
|
currency={currency}
|
|
686
|
+
marketCap={marketCap}
|
|
655
687
|
cellAspect={cellAspect}
|
|
656
688
|
/>
|
|
657
689
|
);
|
|
@@ -674,6 +706,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
|
|
|
674
706
|
tile={tile}
|
|
675
707
|
selected={tile.row.id === selectedId}
|
|
676
708
|
currency={currency}
|
|
709
|
+
marketCap={marketCap}
|
|
677
710
|
onSelect={() => onSelect(tile.row)}
|
|
678
711
|
/>
|
|
679
712
|
))}
|
|
@@ -684,7 +717,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
|
|
|
684
717
|
|
|
685
718
|
function HoldersView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
|
|
686
719
|
const { nativePaneChrome } = useUiCapabilities();
|
|
687
|
-
const { symbol, ticker } = usePaneTicker();
|
|
720
|
+
const { symbol, ticker, financials } = usePaneTicker();
|
|
688
721
|
const dataProvider = useAssetData();
|
|
689
722
|
const [viewMode, setViewMode] = usePluginPaneState<ViewMode>("viewMode", "chart");
|
|
690
723
|
const [sortPreference, setSortPreference] = usePluginPaneState<SortPreference>("sortPreference", DEFAULT_SORT);
|
|
@@ -695,9 +728,11 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
|
|
|
695
728
|
const fetchGenRef = useRef(0);
|
|
696
729
|
|
|
697
730
|
const currency = data?.currency ?? ticker?.metadata.currency ?? "USD";
|
|
731
|
+
const quoteMarketCap = financials?.quote?.marketCap;
|
|
732
|
+
const marketCap = financials?.quote?.currency && financials.quote.currency !== currency ? undefined : quoteMarketCap;
|
|
698
733
|
const exchange = ticker?.metadata.exchange ?? "";
|
|
699
734
|
const rows = useMemo(() => buildRows(data), [data]);
|
|
700
|
-
const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
|
|
735
|
+
const sortedRows = useMemo(() => sortRows(rows, sortPreference, marketCap), [marketCap, rows, sortPreference]);
|
|
701
736
|
const columns = useMemo(() => buildColumns(width), [width]);
|
|
702
737
|
const selectedIdx = selectedId
|
|
703
738
|
? sortedRows.findIndex((row) => row.id === selectedId)
|
|
@@ -833,11 +868,14 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
|
|
|
833
868
|
color: selectedColor ?? (row.changePercent != null ? priceColor(row.changePercent) : colors.textDim),
|
|
834
869
|
};
|
|
835
870
|
case "percentHeld":
|
|
836
|
-
return {
|
|
871
|
+
return {
|
|
872
|
+
text: formatHolderOwnershipPercent(resolveHolderOwnershipPercent(row, marketCap)),
|
|
873
|
+
color: selectedColor ?? colors.textDim,
|
|
874
|
+
};
|
|
837
875
|
case "reportDate":
|
|
838
876
|
return { text: displayDate(row.reportDate), color: selectedColor ?? colors.textDim };
|
|
839
877
|
}
|
|
840
|
-
}, [currency]);
|
|
878
|
+
}, [currency, marketCap]);
|
|
841
879
|
|
|
842
880
|
usePaneFooter("holders", () => ({
|
|
843
881
|
info: [
|
|
@@ -866,6 +904,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
|
|
|
866
904
|
onSelect={(value) => setViewMode(value as ViewMode)}
|
|
867
905
|
compact
|
|
868
906
|
variant="bare"
|
|
907
|
+
focused={focused}
|
|
869
908
|
/>
|
|
870
909
|
</Box>
|
|
871
910
|
|
|
@@ -896,6 +935,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
|
|
|
896
935
|
selectedId={selectedId}
|
|
897
936
|
onSelect={(row) => setSelectedId(row.id)}
|
|
898
937
|
currency={currency}
|
|
938
|
+
marketCap={marketCap}
|
|
899
939
|
/>
|
|
900
940
|
)}
|
|
901
941
|
</Box>
|
|
@@ -197,6 +197,7 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
197
197
|
const [activeTab, setActiveTab] = useState<TabId>("gainers");
|
|
198
198
|
const [quotes, setQuotes] = useState<ScreenerQuote[]>([]);
|
|
199
199
|
const [loading, setLoading] = useState(false);
|
|
200
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
200
201
|
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
|
|
201
202
|
const [sortPreference, setSortPreference] = useState<MarketMoverSortPreference>(DEFAULT_SORT_PREFERENCE);
|
|
202
203
|
const [summaryQuotes, setSummaryQuotes] = useState<MarketSummaryQuote[]>([]);
|
|
@@ -260,12 +261,14 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
260
261
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
261
262
|
setQuotes(cached.data);
|
|
262
263
|
setSelectedSymbol(null);
|
|
264
|
+
setLoadError(null);
|
|
263
265
|
return;
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
fetchGenRef.current += 1;
|
|
267
269
|
const gen = fetchGenRef.current;
|
|
268
270
|
setLoading(true);
|
|
271
|
+
setLoadError(null);
|
|
269
272
|
|
|
270
273
|
try {
|
|
271
274
|
let data: ScreenerQuote[];
|
|
@@ -318,7 +321,12 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
318
321
|
cacheRef.current.set(tab, { data, fetchedAt: Date.now() });
|
|
319
322
|
setQuotes(data);
|
|
320
323
|
setSelectedSymbol(null);
|
|
321
|
-
|
|
324
|
+
setLoadError(null);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (fetchGenRef.current === gen) {
|
|
327
|
+
setLoadError(error instanceof Error ? error.message : "Market movers unavailable");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
322
330
|
finally {
|
|
323
331
|
if (fetchGenRef.current === gen) setLoading(false);
|
|
324
332
|
}
|
|
@@ -337,22 +345,6 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
337
345
|
const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
338
346
|
const key = event.name;
|
|
339
347
|
|
|
340
|
-
if (key === "tab" || key === "right" || key === "l") {
|
|
341
|
-
event.preventDefault?.();
|
|
342
|
-
event.stopPropagation?.();
|
|
343
|
-
const currentTabIdx = TABS.findIndex((t) => t.id === activeTab);
|
|
344
|
-
setActiveTab(TABS[(currentTabIdx + 1) % TABS.length]!.id);
|
|
345
|
-
setSelectedSymbol(null);
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
if (key === "left" || key === "h") {
|
|
349
|
-
event.preventDefault?.();
|
|
350
|
-
event.stopPropagation?.();
|
|
351
|
-
const currentTabIdx = TABS.findIndex((t) => t.id === activeTab);
|
|
352
|
-
setActiveTab(TABS[(currentTabIdx - 1 + TABS.length) % TABS.length]!.id);
|
|
353
|
-
setSelectedSymbol(null);
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
348
|
if (key === "r") {
|
|
357
349
|
event.preventDefault?.();
|
|
358
350
|
event.stopPropagation?.();
|
|
@@ -443,6 +435,7 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
443
435
|
}}
|
|
444
436
|
compact
|
|
445
437
|
variant="bare"
|
|
438
|
+
focused={focused}
|
|
446
439
|
/>
|
|
447
440
|
</Box>
|
|
448
441
|
|
|
@@ -461,7 +454,8 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
461
454
|
onSelect={(row) => setSelectedSymbol(row.symbol)}
|
|
462
455
|
onActivate={(row) => openSymbol(row.symbol)}
|
|
463
456
|
renderCell={renderCell}
|
|
464
|
-
emptyStateTitle={loading ? "Loading movers..." : "No data"}
|
|
457
|
+
emptyStateTitle={loading ? "Loading movers..." : loadError ?? "No data"}
|
|
458
|
+
emptyStateHint={loadError ? "Yahoo Finance did not return market movers." : undefined}
|
|
465
459
|
/>
|
|
466
460
|
</Box>
|
|
467
461
|
);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createYahooScreenerApi,
|
|
4
|
+
fetchScreener,
|
|
5
|
+
parseScreenerResponse,
|
|
6
|
+
parseTrendingResponse,
|
|
7
|
+
} from "./screener";
|
|
3
8
|
|
|
4
9
|
const SAMPLE_SCREENER_RESPONSE = {
|
|
5
10
|
finance: {
|
|
@@ -123,3 +128,36 @@ describe("parseTrendingResponse", () => {
|
|
|
123
128
|
expect(results[0]!.symbol).toBe("SPY");
|
|
124
129
|
});
|
|
125
130
|
});
|
|
131
|
+
|
|
132
|
+
describe("fetchScreener", () => {
|
|
133
|
+
test("falls back to the secondary Yahoo host when the primary host fails", async () => {
|
|
134
|
+
const requestedHosts: string[] = [];
|
|
135
|
+
const userAgents: string[] = [];
|
|
136
|
+
const api = createYahooScreenerApi(async (url, init) => {
|
|
137
|
+
const parsed = new URL(url);
|
|
138
|
+
requestedHosts.push(parsed.host);
|
|
139
|
+
userAgents.push(String((init?.headers as Record<string, string> | undefined)?.["User-Agent"] ?? ""));
|
|
140
|
+
|
|
141
|
+
if (parsed.host === "query2.finance.yahoo.com") {
|
|
142
|
+
return new Response("Forbidden", { status: 403 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
expect(parsed.pathname).toBe("/v1/finance/screener/predefined/saved");
|
|
146
|
+
expect(parsed.searchParams.get("formatted")).toBe("false");
|
|
147
|
+
expect(parsed.searchParams.get("lang")).toBe("en-US");
|
|
148
|
+
expect(parsed.searchParams.get("region")).toBe("US");
|
|
149
|
+
expect(parsed.searchParams.get("scrIds")).toBe("day_gainers");
|
|
150
|
+
expect(parsed.searchParams.get("count")).toBe("2");
|
|
151
|
+
return Response.json(SAMPLE_SCREENER_RESPONSE);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const results = await fetchScreener("day_gainers", 2, api);
|
|
155
|
+
|
|
156
|
+
expect(requestedHosts).toEqual([
|
|
157
|
+
"query2.finance.yahoo.com",
|
|
158
|
+
"query1.finance.yahoo.com",
|
|
159
|
+
]);
|
|
160
|
+
expect(userAgents.every((agent) => agent.includes("Mozilla/5.0"))).toBe(true);
|
|
161
|
+
expect(results.map((result) => result.symbol)).toEqual(["AAPL", "MSFT"]);
|
|
162
|
+
});
|
|
163
|
+
});
|