gloomberb 0.6.1 → 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/patches/@opentui%2Fcore@0.1.90.patch +48 -24
- package/src/app.tsx +158 -57
- package/src/capabilities/factories.ts +3 -0
- package/src/cli/registry.test.ts +0 -1
- package/src/components/chart/chart-controller.ts +0 -1
- package/src/components/chart/chart-data.ts +1 -31
- package/src/components/chart/chart-renderer.ts +94 -12
- package/src/components/chart/chart-resolution.test.ts +0 -36
- package/src/components/chart/chart-resolution.ts +0 -27
- package/src/components/chart/chart.test.ts +60 -45
- package/src/components/chart/comparison-chart-data.test.ts +1 -5
- package/src/components/chart/comparison-chart-data.ts +1 -4
- 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 +63 -39
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +30 -4
- package/src/components/chart/stock-chart.test.ts +0 -16
- package/src/components/chart/stock-chart.tsx +69 -41
- package/src/components/chart/time-axis-label.tsx +120 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +182 -36
- package/src/components/command-bar/command-bar.tsx +943 -1016
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/confirm-body.tsx +51 -0
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +76 -0
- 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 -20
- package/src/components/command-bar/workflow-ops.ts +4 -0
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/data-table-stack-view.test.tsx +39 -4
- package/src/components/data-table-stack-view.tsx +11 -104
- package/src/components/data-table-view.tsx +43 -79
- package/src/components/feed-data-table-stack-view.tsx +10 -17
- package/src/components/feed-stack-controller.ts +39 -0
- package/src/components/index.ts +5 -1
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +35 -4
- package/src/components/layout/header.tsx +12 -7
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-cycle-order.test.ts +30 -0
- package/src/components/layout/pane-cycle-order.ts +32 -0
- package/src/components/layout/pane-footer.tsx +11 -4
- package/src/components/layout/pane-header.tsx +5 -5
- 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 +264 -22
- package/src/components/layout/shell.tsx +46 -8
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1 -2
- package/src/components/pane-settings-dialog.tsx +12 -4
- package/src/components/pane-template-wizard.test.tsx +3 -4
- package/src/components/speedometer-gauge.test.tsx +45 -0
- package/src/components/speedometer-gauge.tsx +562 -0
- package/src/components/table-view-shared.tsx +218 -0
- package/src/components/ticker-list-table-view.tsx +57 -73
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/confirm-dialog.tsx +66 -0
- package/src/components/ui/data-table.tsx +30 -19
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/index.ts +0 -1
- package/src/components/ui/page-stack-view.tsx +39 -10
- package/src/components/ui/table-layout.test.ts +42 -0
- package/src/components/ui/table-layout.ts +49 -4
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +168 -21
- package/src/core/state/app-state.ts +10 -17
- package/src/market-data/coordinator.test.ts +76 -1
- package/src/market-data/coordinator.ts +218 -49
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -3
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
- package/src/plugins/builtin/ai/providers.ts +0 -17
- package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/ask-ai.test.tsx +89 -2
- package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
- package/src/plugins/builtin/broker-manager/index.tsx +19 -36
- package/src/plugins/builtin/chat-controller.test.ts +188 -1
- package/src/plugins/builtin/chat-controller.ts +537 -220
- package/src/plugins/builtin/chat.test.tsx +431 -13
- package/src/plugins/builtin/chat.tsx +1134 -227
- package/src/plugins/builtin/cloud-tweets.tsx +1 -1
- package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
- package/src/plugins/builtin/comparison-chart.tsx +11 -5
- package/src/plugins/builtin/correlation/index.tsx +1 -6
- package/src/plugins/builtin/debug.tsx +1 -3
- package/src/plugins/builtin/econ/index.tsx +1 -2
- package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
- package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
- package/src/plugins/builtin/fear-greed/index.tsx +587 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/help.tsx +2 -2
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +64 -29
- package/src/plugins/builtin/insider/index.tsx +22 -89
- package/src/plugins/builtin/market-movers/index.tsx +13 -19
- 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/index.tsx +41 -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/news-wire/news-table.tsx +11 -11
- package/src/plugins/builtin/news.tsx +4 -16
- package/src/plugins/builtin/notes.tsx +19 -55
- package/src/plugins/builtin/options.test.tsx +58 -2
- package/src/plugins/builtin/options.tsx +110 -65
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
- 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 +164 -33
- package/src/plugins/builtin/sec-filing-display.tsx +84 -0
- package/src/plugins/builtin/sec.tsx +13 -66
- package/src/plugins/builtin/sectors/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -1
- package/src/plugins/builtin/yield-curve/index.tsx +1 -1
- package/src/plugins/ibkr/broker-adapter.ts +1 -3
- package/src/plugins/ibkr/gateway-service-native.ts +1 -5
- package/src/plugins/ibkr/gateway-service.ts +1 -1
- package/src/plugins/ibkr/index.test.tsx +0 -2
- package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
- package/src/plugins/pane-manager.ts +0 -13
- package/src/plugins/plugin-runtime.test.tsx +40 -6
- package/src/plugins/plugin-runtime.tsx +20 -10
- package/src/plugins/prediction-markets/cli.ts +0 -1
- package/src/plugins/prediction-markets/controller-data.ts +0 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/prediction-markets/pane.test.tsx +3 -11
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
- package/src/plugins/registry.ts +13 -42
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +2 -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 +138 -101
- package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
- package/src/renderers/electrobun/view/input-host.tsx +74 -10
- package/src/renderers/electrobun/view/key-event.ts +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +329 -0
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/chart-surface.tsx +179 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/renderers/opentui/start.tsx +48 -30
- package/src/renderers/opentui/test-utils.tsx +17 -0
- package/src/renderers/opentui/ui-host.tsx +5 -2
- 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 -15
- 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 +65 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.test.tsx +73 -0
- package/src/state/use-inline-tickers.ts +19 -10
- package/src/test-support/plugin-runtime.ts +3 -2
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +76 -6
- package/src/theme/theme-context.tsx +26 -0
- package/src/theme/themes.test.ts +23 -1
- package/src/theme/themes.ts +115 -0
- package/src/types/broker.ts +1 -1
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -2
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -3
- package/src/ui/host.tsx +7 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/back-navigation.ts +24 -1
- 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
- package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
- package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
|
@@ -546,7 +546,6 @@ function TweetSearchTable({
|
|
|
546
546
|
emptyContent={emptyContent}
|
|
547
547
|
emptyStateTitle={loading ? "Loading tweets..." : error ?? "No tweets"}
|
|
548
548
|
emptyStateHint={data?.query}
|
|
549
|
-
showHorizontalScrollbar={false}
|
|
550
549
|
/>
|
|
551
550
|
);
|
|
552
551
|
}
|
|
@@ -864,6 +863,7 @@ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
|
|
|
864
863
|
variant="pill"
|
|
865
864
|
closeMode="active"
|
|
866
865
|
onAdd={editorState ? undefined : openCreateEditor}
|
|
866
|
+
focused={focused && !editorState}
|
|
867
867
|
/>
|
|
868
868
|
</Box>
|
|
869
869
|
|
|
@@ -109,7 +109,7 @@ function createRegistrySpy(spy: { selected: string[]; focused: string[] }): Plug
|
|
|
109
109
|
} as unknown as PluginRegistry;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function createRuntimeSpy(spy: { selected: string[]; focused: string[] }): PluginRuntimeAccess {
|
|
112
|
+
function createRuntimeSpy(spy: { selected: string[]; focused: string[]; settings?: string[] }): PluginRuntimeAccess {
|
|
113
113
|
return createTestPluginRuntime({
|
|
114
114
|
pinTicker: (symbol: string) => {
|
|
115
115
|
spy.selected.push(symbol);
|
|
@@ -119,6 +119,9 @@ function createRuntimeSpy(spy: { selected: string[]; focused: string[] }): Plugi
|
|
|
119
119
|
spy.selected.push(symbol);
|
|
120
120
|
spy.focused.push("ticker-detail");
|
|
121
121
|
},
|
|
122
|
+
openPaneSettings: (paneId) => {
|
|
123
|
+
spy.settings?.push(paneId ?? "");
|
|
124
|
+
},
|
|
122
125
|
});
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -196,7 +199,7 @@ async function mountComparisonHarness(
|
|
|
196
199
|
settings: Record<string, unknown>,
|
|
197
200
|
tickers: TickerRecord[],
|
|
198
201
|
financials: Array<[string, TickerFinancials]>,
|
|
199
|
-
spy: { selected: string[]; focused: string[] } = { selected: [], focused: [] },
|
|
202
|
+
spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
|
|
200
203
|
) {
|
|
201
204
|
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
202
205
|
testSetup = await createTestRenderer({ width: 120, height: 20 });
|
|
@@ -251,6 +254,21 @@ describe("comparisonChartPlugin", () => {
|
|
|
251
254
|
});
|
|
252
255
|
});
|
|
253
256
|
|
|
257
|
+
test("requires at least two symbols when creating a comparison pane", async () => {
|
|
258
|
+
const template = comparisonChartPlugin.paneTemplates?.[0]!;
|
|
259
|
+
const context = {
|
|
260
|
+
config: createDefaultConfig("/tmp/gloomberb-compare"),
|
|
261
|
+
layout: { dockRoot: null, instances: [], floating: [], detached: [] },
|
|
262
|
+
focusedPaneId: null,
|
|
263
|
+
activeTicker: null,
|
|
264
|
+
activeCollectionId: null,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
expect(template.canCreate?.(context, { arg: "AMD" })).toBe(true);
|
|
268
|
+
expect(template.canCreate?.(context, { arg: "AMD", symbols: ["AMD"] })).toBe(false);
|
|
269
|
+
expect(await template.createInstance?.(context, { symbols: ["AMD"] })).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
254
272
|
test("renders one shared overlay chart with the mixed-currency warning", async () => {
|
|
255
273
|
const provider = createProvider({
|
|
256
274
|
AAPL: [100, 102, 104, 106],
|
|
@@ -284,6 +302,7 @@ describe("comparisonChartPlugin", () => {
|
|
|
284
302
|
expect(frame).toContain("2:1W");
|
|
285
303
|
expect(frame).toContain("1D");
|
|
286
304
|
expect(frame).toContain("view:");
|
|
305
|
+
expect(frame).toContain("[t]ickers");
|
|
287
306
|
expect(frame).toContain("[m]ode");
|
|
288
307
|
expect(frame).toContain("[r]es");
|
|
289
308
|
expect(frame).not.toContain("[up/down]legend");
|
|
@@ -364,4 +383,36 @@ describe("comparisonChartPlugin", () => {
|
|
|
364
383
|
expect(spy.selected).toEqual(["MSFT"]);
|
|
365
384
|
expect(spy.focused).toEqual(["ticker-detail"]);
|
|
366
385
|
});
|
|
386
|
+
|
|
387
|
+
test("opens comparison ticker settings from the t shortcut", async () => {
|
|
388
|
+
const spy = { selected: [] as string[], focused: [] as string[], settings: [] as string[] };
|
|
389
|
+
const provider = createProvider({
|
|
390
|
+
AAPL: [100, 102, 104],
|
|
391
|
+
MSFT: [200, 202, 204],
|
|
392
|
+
}, {
|
|
393
|
+
AAPL: "USD",
|
|
394
|
+
MSFT: "USD",
|
|
395
|
+
});
|
|
396
|
+
setSharedMarketDataForTests(provider);
|
|
397
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
398
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
399
|
+
setSharedRegistryForTests(createRegistrySpy(spy));
|
|
400
|
+
|
|
401
|
+
await mountComparisonHarness({
|
|
402
|
+
axisMode: "percent",
|
|
403
|
+
symbols: ["AAPL", "MSFT"],
|
|
404
|
+
symbolsText: "AAPL, MSFT",
|
|
405
|
+
}, [
|
|
406
|
+
makeTicker("AAPL", "USD"),
|
|
407
|
+
makeTicker("MSFT", "USD"),
|
|
408
|
+
], [
|
|
409
|
+
["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
|
|
410
|
+
["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
|
|
411
|
+
], spy);
|
|
412
|
+
|
|
413
|
+
await flushFrames();
|
|
414
|
+
await pressComparisonInput(() => testSetup!.mockInput.pressKey("t"));
|
|
415
|
+
|
|
416
|
+
expect(spy.settings).toEqual([TEST_PANE_ID]);
|
|
417
|
+
});
|
|
367
418
|
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_COMPARISON_CHART_RESOLUTION,
|
|
9
9
|
normalizeChartResolution,
|
|
10
10
|
} from "../../components/chart/chart-resolution";
|
|
11
|
-
import { usePluginTickerActions } from "../../plugins/plugin-runtime";
|
|
11
|
+
import { usePluginAppActions, usePluginTickerActions } from "../../plugins/plugin-runtime";
|
|
12
12
|
import { usePaneInstance } from "../../state/app-context";
|
|
13
13
|
import { colors } from "../../theme/colors";
|
|
14
14
|
import type { GloomPlugin, PaneProps, PaneSettingsDef } from "../../types/plugin";
|
|
@@ -16,6 +16,7 @@ import { formatTickerListInput, MAX_TICKER_LIST_SIZE, parseTickerListInput } fro
|
|
|
16
16
|
|
|
17
17
|
export const COMPARISON_CHART_PANE_ID = "comparison-chart";
|
|
18
18
|
export const COMPARISON_CHART_TEMPLATE_ID = "comparison-chart-pane";
|
|
19
|
+
export const MIN_COMPARISON_CHART_SYMBOLS = 2;
|
|
19
20
|
|
|
20
21
|
interface ComparisonChartPaneSettings {
|
|
21
22
|
axisMode: ChartAxisMode;
|
|
@@ -77,7 +78,7 @@ function buildComparisonChartSettingsDef(): PaneSettingsDef {
|
|
|
77
78
|
{
|
|
78
79
|
key: "symbolsText",
|
|
79
80
|
label: "Tickers",
|
|
80
|
-
description: `Enter
|
|
81
|
+
description: `Enter ${MIN_COMPARISON_CHART_SYMBOLS}-${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
|
|
81
82
|
type: "text",
|
|
82
83
|
placeholder: "AAPL, MSFT, NVDA",
|
|
83
84
|
},
|
|
@@ -102,12 +103,16 @@ export function buildComparisonChartPaneTitle(symbols: string[]): string {
|
|
|
102
103
|
}
|
|
103
104
|
function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
|
|
104
105
|
const { navigateTicker } = usePluginTickerActions();
|
|
106
|
+
const { openPaneSettings } = usePluginAppActions();
|
|
105
107
|
const pane = usePaneInstance();
|
|
106
108
|
const settings = useMemo(() => getComparisonChartPaneSettings(pane?.settings), [pane?.settings]);
|
|
107
109
|
|
|
108
110
|
const openTicker = useCallback((symbol: string) => {
|
|
109
111
|
navigateTicker(symbol);
|
|
110
112
|
}, [navigateTicker]);
|
|
113
|
+
const editTickers = useCallback(() => {
|
|
114
|
+
openPaneSettings(paneId);
|
|
115
|
+
}, [openPaneSettings, paneId]);
|
|
111
116
|
|
|
112
117
|
if (settings.symbols.length === 0) {
|
|
113
118
|
return (
|
|
@@ -127,6 +132,7 @@ function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
|
|
|
127
132
|
symbols={settings.symbols}
|
|
128
133
|
axisMode={settings.axisMode}
|
|
129
134
|
onOpenSymbol={openTicker}
|
|
135
|
+
onEditTickers={editTickers}
|
|
130
136
|
/>
|
|
131
137
|
</Box>
|
|
132
138
|
);
|
|
@@ -162,15 +168,15 @@ export const comparisonChartPlugin: GloomPlugin = {
|
|
|
162
168
|
label: "Comparison Tickers",
|
|
163
169
|
placeholder: "AAPL, MSFT, NVDA",
|
|
164
170
|
body: [
|
|
165
|
-
`Enter
|
|
171
|
+
`Enter ${MIN_COMPARISON_CHART_SYMBOLS}-${MAX_TICKER_LIST_SIZE} ticker symbols separated by commas.`,
|
|
166
172
|
],
|
|
167
173
|
type: "text",
|
|
168
174
|
},
|
|
169
175
|
],
|
|
170
|
-
canCreate: (_context, options) => !options?.symbols || options.symbols.length
|
|
176
|
+
canCreate: (_context, options) => !options?.symbols || options.symbols.length >= MIN_COMPARISON_CHART_SYMBOLS,
|
|
171
177
|
createInstance: (_context, options) => {
|
|
172
178
|
const symbols = options?.symbols ?? [];
|
|
173
|
-
if (symbols.length
|
|
179
|
+
if (symbols.length < MIN_COMPARISON_CHART_SYMBOLS) return null;
|
|
174
180
|
return {
|
|
175
181
|
title: buildComparisonChartPaneTitle(symbols),
|
|
176
182
|
placement: "floating",
|
|
@@ -34,11 +34,6 @@ const MIN_CORRELATION_OBSERVATIONS = 5;
|
|
|
34
34
|
|
|
35
35
|
type SeriesStatus = "loading" | "ready" | "insufficient" | "empty" | "error";
|
|
36
36
|
|
|
37
|
-
interface CorrelationInstrument {
|
|
38
|
-
symbol: string;
|
|
39
|
-
exchange: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
37
|
interface CorrelationSeries {
|
|
43
38
|
symbol: string;
|
|
44
39
|
returns: DatedReturn[];
|
|
@@ -147,7 +142,7 @@ function buildCorrelationPaneTitle(symbols: string[], rangePreset: CorrelationRa
|
|
|
147
142
|
return `${symbols.slice(0, 2).join(" · ")} +${symbols.length - 2} ${rangePreset}`;
|
|
148
143
|
}
|
|
149
144
|
|
|
150
|
-
|
|
145
|
+
function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
151
146
|
const pane = usePaneInstance();
|
|
152
147
|
const { navigateTicker } = usePluginTickerActions();
|
|
153
148
|
const tickers = useAppSelector((state) => state.tickers);
|
|
@@ -62,7 +62,6 @@ function DebugPane({ focused, width, height }: PaneProps) {
|
|
|
62
62
|
const [filterLevel, setFilterLevel] = useState<LogLevel | null>(null);
|
|
63
63
|
const [filterSource, setFilterSource] = useState<string | null>(null);
|
|
64
64
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
65
|
-
const [scrollOffset, setScrollOffset] = useState(0);
|
|
66
65
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
|
67
66
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
|
68
67
|
const [showDetail, setShowDetail] = useState(false);
|
|
@@ -77,7 +76,6 @@ function DebugPane({ focused, width, height }: PaneProps) {
|
|
|
77
76
|
);
|
|
78
77
|
setEntries(filtered);
|
|
79
78
|
sourcesRef.current = debugLog.getSources();
|
|
80
|
-
if (autoScroll) setScrollOffset(0);
|
|
81
79
|
});
|
|
82
80
|
return unsub;
|
|
83
81
|
}, [filterLevel, filterSource, autoScroll]);
|
|
@@ -342,7 +340,7 @@ export const debugPlugin: GloomPlugin = {
|
|
|
342
340
|
keywords: ["debug", "log", "logs", "console", "errors"],
|
|
343
341
|
category: "navigation",
|
|
344
342
|
execute: () => {
|
|
345
|
-
ctx.
|
|
343
|
+
ctx.showPane("debug");
|
|
346
344
|
},
|
|
347
345
|
});
|
|
348
346
|
|
|
@@ -303,7 +303,6 @@ function EconDetailView({ event, width, height, focused }: EconDetailViewProps)
|
|
|
303
303
|
return { date: obs.date, display: obs.value.toLocaleString("en-US", { maximumFractionDigits: 1 }) };
|
|
304
304
|
});
|
|
305
305
|
const units = info?.units ?? "";
|
|
306
|
-
const source = info?.source || "";
|
|
307
306
|
const title = info?.title ?? event.event;
|
|
308
307
|
|
|
309
308
|
// Value color for table rows
|
|
@@ -472,7 +471,7 @@ function formatCompactAxisValue(value: number, units: string): string {
|
|
|
472
471
|
// Main Calendar Pane
|
|
473
472
|
// ---------------------------------------------------------------------------
|
|
474
473
|
|
|
475
|
-
|
|
474
|
+
function EconCalendarPane({ focused, width, height }: PaneProps) {
|
|
476
475
|
const [events, setEvents] = useState<EconEvent[]>(sharedCache?.data ?? []);
|
|
477
476
|
const [loading, setLoading] = useState(false);
|
|
478
477
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { normalizeFearGreedData, type CnnFearGreedGraphData } from "./fear-greed-data";
|
|
3
|
+
|
|
4
|
+
describe("fear-greed data normalization", () => {
|
|
5
|
+
test("combines latest score with chart history and aligned overlay series", () => {
|
|
6
|
+
const charts: CnnFearGreedGraphData = {
|
|
7
|
+
fear_and_greed: {
|
|
8
|
+
score: 65,
|
|
9
|
+
rating: "greed",
|
|
10
|
+
timestamp: "2026-05-08T23:59:55+00:00",
|
|
11
|
+
},
|
|
12
|
+
fear_and_greed_historical: {
|
|
13
|
+
score: 65,
|
|
14
|
+
rating: "greed",
|
|
15
|
+
data: [
|
|
16
|
+
{ x: 2000, y: 62, rating: "greed" },
|
|
17
|
+
{ x: 1000, y: 58, rating: "greed" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
market_momentum_sp500: {
|
|
21
|
+
score: 99.6,
|
|
22
|
+
rating: "extreme greed",
|
|
23
|
+
timestamp: 2000,
|
|
24
|
+
data: [
|
|
25
|
+
{ x: 1000, y: 100 },
|
|
26
|
+
{ x: 2000, y: 110 },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
market_momentum_sp125: {
|
|
30
|
+
timestamp: 2000,
|
|
31
|
+
data: [
|
|
32
|
+
{ x: 1000, y: 92 },
|
|
33
|
+
{ x: 2000, y: 96 },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
stock_price_strength: {
|
|
37
|
+
score: 61.4,
|
|
38
|
+
rating: "greed",
|
|
39
|
+
data: [{ x: 1000, y: 3.8 }],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const latest: CnnFearGreedGraphData = {
|
|
43
|
+
fear_and_greed: {
|
|
44
|
+
score: 70.4,
|
|
45
|
+
rating: "greed",
|
|
46
|
+
timestamp: "2026-05-09T12:00:00+00:00",
|
|
47
|
+
previous_close: 67.5,
|
|
48
|
+
previous_1_week: 71.1,
|
|
49
|
+
previous_1_month: 29.1,
|
|
50
|
+
previous_1_year: 57.6,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const data = normalizeFearGreedData(charts, latest);
|
|
55
|
+
|
|
56
|
+
expect(data.overall.score).toBe(70.4);
|
|
57
|
+
expect(data.overall.previousClose).toBe(67.5);
|
|
58
|
+
expect(data.overall.history.map((point) => point.close)).toEqual([58, 62]);
|
|
59
|
+
|
|
60
|
+
const momentum = data.indicators.find((indicator) => indicator.definition.id === "market-momentum");
|
|
61
|
+
expect(momentum?.rating).toBe("extreme greed");
|
|
62
|
+
expect(momentum?.points.map((point) => point.close)).toEqual([100, 110]);
|
|
63
|
+
expect(momentum?.secondaryPoints).toEqual([
|
|
64
|
+
{ index: 0, value: 92 },
|
|
65
|
+
{ index: 1, value: 96 },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects responses without an index score", () => {
|
|
70
|
+
expect(() => normalizeFearGreedData({})).toThrow("index score");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { ProjectedChartPoint } from "../../../components/chart/chart-data";
|
|
2
|
+
import type { OverlayPoint } from "../../../components/chart/indicators/types";
|
|
3
|
+
|
|
4
|
+
const CNN_FEAR_GREED_GRAPH_URL = "https://production.dataviz.cnn.io/index/fearandgreed/graphdata";
|
|
5
|
+
const CNN_REFERER = "https://www.cnn.com/markets/fear-and-greed";
|
|
6
|
+
const CNN_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
export type FearGreedRating = "extreme fear" | "fear" | "neutral" | "greed" | "extreme greed";
|
|
9
|
+
|
|
10
|
+
type CnnGraphKey =
|
|
11
|
+
| "fear_and_greed"
|
|
12
|
+
| "fear_and_greed_historical"
|
|
13
|
+
| "market_momentum_sp500"
|
|
14
|
+
| "market_momentum_sp125"
|
|
15
|
+
| "stock_price_strength"
|
|
16
|
+
| "stock_price_breadth"
|
|
17
|
+
| "put_call_options"
|
|
18
|
+
| "market_volatility_vix"
|
|
19
|
+
| "market_volatility_vix_50"
|
|
20
|
+
| "safe_haven_demand"
|
|
21
|
+
| "junk_bond_demand";
|
|
22
|
+
|
|
23
|
+
interface CnnPoint {
|
|
24
|
+
x?: unknown;
|
|
25
|
+
y?: unknown;
|
|
26
|
+
rating?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CnnSeries {
|
|
30
|
+
timestamp?: unknown;
|
|
31
|
+
score?: unknown;
|
|
32
|
+
rating?: unknown;
|
|
33
|
+
previous_close?: unknown;
|
|
34
|
+
previous_1_week?: unknown;
|
|
35
|
+
previous_1_month?: unknown;
|
|
36
|
+
previous_1_year?: unknown;
|
|
37
|
+
data?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CnnFearGreedGraphData = Partial<Record<CnnGraphKey, CnnSeries>>;
|
|
41
|
+
|
|
42
|
+
interface NormalizedRawPoint {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
rating: FearGreedRating | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FearGreedOverall {
|
|
49
|
+
score: number;
|
|
50
|
+
rating: FearGreedRating;
|
|
51
|
+
updatedAt: Date | null;
|
|
52
|
+
previousClose: number | null;
|
|
53
|
+
previousWeek: number | null;
|
|
54
|
+
previousMonth: number | null;
|
|
55
|
+
previousYear: number | null;
|
|
56
|
+
history: ProjectedChartPoint[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type FearGreedValueFormat = "score" | "number" | "percent" | "ratio";
|
|
60
|
+
|
|
61
|
+
export interface FearGreedIndicatorDefinition {
|
|
62
|
+
id: string;
|
|
63
|
+
title: string;
|
|
64
|
+
subtitle: string;
|
|
65
|
+
primaryKey: CnnGraphKey;
|
|
66
|
+
primaryLabel: string;
|
|
67
|
+
secondaryKey?: CnnGraphKey;
|
|
68
|
+
secondaryLabel?: string;
|
|
69
|
+
valueFormat: FearGreedValueFormat;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FearGreedIndicator {
|
|
73
|
+
definition: FearGreedIndicatorDefinition;
|
|
74
|
+
score: number | null;
|
|
75
|
+
rating: FearGreedRating;
|
|
76
|
+
updatedAt: Date | null;
|
|
77
|
+
points: ProjectedChartPoint[];
|
|
78
|
+
secondaryPoints: OverlayPoint[];
|
|
79
|
+
latestValue: number | null;
|
|
80
|
+
latestSecondaryValue: number | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FearGreedData {
|
|
84
|
+
overall: FearGreedOverall;
|
|
85
|
+
indicators: FearGreedIndicator[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const FEAR_GREED_INDICATORS: FearGreedIndicatorDefinition[] = [
|
|
89
|
+
{
|
|
90
|
+
id: "market-momentum",
|
|
91
|
+
title: "Market Momentum",
|
|
92
|
+
subtitle: "S&P 500 and its 125-day moving average",
|
|
93
|
+
primaryKey: "market_momentum_sp500",
|
|
94
|
+
primaryLabel: "S&P 500",
|
|
95
|
+
secondaryKey: "market_momentum_sp125",
|
|
96
|
+
secondaryLabel: "125-day moving average",
|
|
97
|
+
valueFormat: "number",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "stock-price-strength",
|
|
101
|
+
title: "Stock Price Strength",
|
|
102
|
+
subtitle: "Net new 52-week highs and lows on the NYSE",
|
|
103
|
+
primaryKey: "stock_price_strength",
|
|
104
|
+
primaryLabel: "Net highs/lows",
|
|
105
|
+
valueFormat: "percent",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "stock-price-breadth",
|
|
109
|
+
title: "Stock Price Breadth",
|
|
110
|
+
subtitle: "McClellan Volume Summation Index",
|
|
111
|
+
primaryKey: "stock_price_breadth",
|
|
112
|
+
primaryLabel: "McClellan index",
|
|
113
|
+
valueFormat: "number",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "put-call-options",
|
|
117
|
+
title: "Put and Call Options",
|
|
118
|
+
subtitle: "5-day average put/call ratio",
|
|
119
|
+
primaryKey: "put_call_options",
|
|
120
|
+
primaryLabel: "Put/call ratio",
|
|
121
|
+
valueFormat: "ratio",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "market-volatility",
|
|
125
|
+
title: "Market Volatility",
|
|
126
|
+
subtitle: "VIX and its 50-day moving average",
|
|
127
|
+
primaryKey: "market_volatility_vix",
|
|
128
|
+
primaryLabel: "VIX",
|
|
129
|
+
secondaryKey: "market_volatility_vix_50",
|
|
130
|
+
secondaryLabel: "50-day moving average",
|
|
131
|
+
valueFormat: "number",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "safe-haven-demand",
|
|
135
|
+
title: "Safe Haven Demand",
|
|
136
|
+
subtitle: "Difference in 20-day stock and bond returns",
|
|
137
|
+
primaryKey: "safe_haven_demand",
|
|
138
|
+
primaryLabel: "Stocks vs. bonds",
|
|
139
|
+
valueFormat: "percent",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "junk-bond-demand",
|
|
143
|
+
title: "Junk Bond Demand",
|
|
144
|
+
subtitle: "Yield spread: junk bonds vs. investment grade",
|
|
145
|
+
primaryKey: "junk_bond_demand",
|
|
146
|
+
primaryLabel: "Yield spread",
|
|
147
|
+
valueFormat: "percent",
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
152
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function finiteNumber(value: unknown): number | null {
|
|
156
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeRating(value: unknown): FearGreedRating | null {
|
|
161
|
+
if (typeof value !== "string") return null;
|
|
162
|
+
const normalized = value.trim().toLowerCase();
|
|
163
|
+
switch (normalized) {
|
|
164
|
+
case "extreme fear":
|
|
165
|
+
case "fear":
|
|
166
|
+
case "neutral":
|
|
167
|
+
case "greed":
|
|
168
|
+
case "extreme greed":
|
|
169
|
+
return normalized;
|
|
170
|
+
default:
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fallbackRatingForScore(score: number | null): FearGreedRating {
|
|
176
|
+
if (score == null) return "neutral";
|
|
177
|
+
if (score < 25) return "extreme fear";
|
|
178
|
+
if (score < 45) return "fear";
|
|
179
|
+
if (score <= 55) return "neutral";
|
|
180
|
+
if (score <= 75) return "greed";
|
|
181
|
+
return "extreme greed";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseTimestamp(value: unknown): Date | null {
|
|
185
|
+
if (typeof value === "string") {
|
|
186
|
+
const parsed = new Date(value);
|
|
187
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
188
|
+
}
|
|
189
|
+
const millis = finiteNumber(value);
|
|
190
|
+
if (millis == null) return null;
|
|
191
|
+
const parsed = new Date(millis);
|
|
192
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeRawPoints(series: CnnSeries | undefined): NormalizedRawPoint[] {
|
|
196
|
+
if (!series || !Array.isArray(series.data)) return [];
|
|
197
|
+
const points: NormalizedRawPoint[] = [];
|
|
198
|
+
for (const item of series.data) {
|
|
199
|
+
if (!isObject(item)) continue;
|
|
200
|
+
const point = item as CnnPoint;
|
|
201
|
+
const x = finiteNumber(point.x);
|
|
202
|
+
const y = finiteNumber(point.y);
|
|
203
|
+
if (x == null || y == null) continue;
|
|
204
|
+
points.push({ x, y, rating: normalizeRating(point.rating) });
|
|
205
|
+
}
|
|
206
|
+
return points.sort((left, right) => left.x - right.x);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function toProjectedPoints(points: NormalizedRawPoint[]): ProjectedChartPoint[] {
|
|
210
|
+
return points.map((point) => ({
|
|
211
|
+
date: new Date(point.x),
|
|
212
|
+
open: point.y,
|
|
213
|
+
high: point.y,
|
|
214
|
+
low: point.y,
|
|
215
|
+
close: point.y,
|
|
216
|
+
volume: 0,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function toSecondaryOverlay(primaryPoints: NormalizedRawPoint[], secondaryPoints: NormalizedRawPoint[]): OverlayPoint[] {
|
|
221
|
+
const indexByTimestamp = new Map(primaryPoints.map((point, index) => [point.x, index] as const));
|
|
222
|
+
const overlay: OverlayPoint[] = [];
|
|
223
|
+
for (let index = 0; index < secondaryPoints.length; index += 1) {
|
|
224
|
+
const point = secondaryPoints[index]!;
|
|
225
|
+
const matchedIndex = indexByTimestamp.get(point.x) ?? (index < primaryPoints.length ? index : null);
|
|
226
|
+
if (matchedIndex == null) continue;
|
|
227
|
+
overlay.push({ index: matchedIndex, value: point.y });
|
|
228
|
+
}
|
|
229
|
+
return overlay;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function latestValue(points: NormalizedRawPoint[]): number | null {
|
|
233
|
+
return points.length > 0 ? points[points.length - 1]!.y : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function latestRating(series: CnnSeries | undefined, points: NormalizedRawPoint[], score: number | null): FearGreedRating {
|
|
237
|
+
return normalizeRating(series?.rating)
|
|
238
|
+
?? (points.length > 0 ? points[points.length - 1]!.rating : null)
|
|
239
|
+
?? fallbackRatingForScore(score);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function latestTimestamp(series: CnnSeries | undefined, points: NormalizedRawPoint[]): Date | null {
|
|
243
|
+
return parseTimestamp(series?.timestamp)
|
|
244
|
+
?? (points.length > 0 ? new Date(points[points.length - 1]!.x) : null);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function normalizeFearGreedData(
|
|
248
|
+
charts: CnnFearGreedGraphData,
|
|
249
|
+
latest: CnnFearGreedGraphData = charts,
|
|
250
|
+
): FearGreedData {
|
|
251
|
+
const latestOverallSeries = latest.fear_and_greed ?? charts.fear_and_greed;
|
|
252
|
+
const historySeries = charts.fear_and_greed_historical ?? latest.fear_and_greed_historical;
|
|
253
|
+
const historyRawPoints = normalizeRawPoints(historySeries);
|
|
254
|
+
const latestScore = finiteNumber(latestOverallSeries?.score)
|
|
255
|
+
?? finiteNumber(historySeries?.score)
|
|
256
|
+
?? latestValue(historyRawPoints);
|
|
257
|
+
|
|
258
|
+
if (latestScore == null) {
|
|
259
|
+
throw new Error("CNN Fear & Greed response did not include an index score");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const overall: FearGreedOverall = {
|
|
263
|
+
score: latestScore,
|
|
264
|
+
rating: normalizeRating(latestOverallSeries?.rating) ?? fallbackRatingForScore(latestScore),
|
|
265
|
+
updatedAt: parseTimestamp(latestOverallSeries?.timestamp) ?? latestTimestamp(historySeries, historyRawPoints),
|
|
266
|
+
previousClose: finiteNumber(latestOverallSeries?.previous_close),
|
|
267
|
+
previousWeek: finiteNumber(latestOverallSeries?.previous_1_week),
|
|
268
|
+
previousMonth: finiteNumber(latestOverallSeries?.previous_1_month),
|
|
269
|
+
previousYear: finiteNumber(latestOverallSeries?.previous_1_year),
|
|
270
|
+
history: toProjectedPoints(historyRawPoints),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const indicators = FEAR_GREED_INDICATORS.flatMap((definition): FearGreedIndicator[] => {
|
|
274
|
+
const series = charts[definition.primaryKey] ?? latest[definition.primaryKey];
|
|
275
|
+
const primaryPoints = normalizeRawPoints(series);
|
|
276
|
+
if (primaryPoints.length === 0) return [];
|
|
277
|
+
|
|
278
|
+
const secondarySeries = definition.secondaryKey
|
|
279
|
+
? (charts[definition.secondaryKey] ?? latest[definition.secondaryKey])
|
|
280
|
+
: undefined;
|
|
281
|
+
const secondaryRawPoints = normalizeRawPoints(secondarySeries);
|
|
282
|
+
const score = finiteNumber(series?.score);
|
|
283
|
+
|
|
284
|
+
return [{
|
|
285
|
+
definition,
|
|
286
|
+
score,
|
|
287
|
+
rating: latestRating(series, primaryPoints, score),
|
|
288
|
+
updatedAt: latestTimestamp(series, primaryPoints),
|
|
289
|
+
points: toProjectedPoints(primaryPoints),
|
|
290
|
+
secondaryPoints: toSecondaryOverlay(primaryPoints, secondaryRawPoints),
|
|
291
|
+
latestValue: latestValue(primaryPoints),
|
|
292
|
+
latestSecondaryValue: latestValue(secondaryRawPoints),
|
|
293
|
+
}];
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return { overall, indicators };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatLocalDate(date: Date): string {
|
|
300
|
+
const year = date.getFullYear();
|
|
301
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
302
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
303
|
+
return `${year}-${month}-${day}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isDomRuntime(): boolean {
|
|
307
|
+
return typeof (globalThis as { document?: unknown }).document !== "undefined";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cnnFetchHeaders(): HeadersInit {
|
|
311
|
+
const headers: Record<string, string> = {
|
|
312
|
+
Accept: "application/json,text/plain,*/*",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (!isDomRuntime()) {
|
|
316
|
+
headers["User-Agent"] = CNN_USER_AGENT;
|
|
317
|
+
headers.Referer = CNN_REFERER;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return headers;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function fetchCnnGraphData(url: string, fetcher: typeof fetch): Promise<CnnFearGreedGraphData> {
|
|
324
|
+
const response = await fetcher(url, { headers: cnnFetchHeaders() });
|
|
325
|
+
const body = await response.text();
|
|
326
|
+
if (!response.ok) {
|
|
327
|
+
throw new Error(`CNN Fear & Greed request failed (${response.status}): ${body.slice(0, 120)}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let parsed: unknown;
|
|
331
|
+
try {
|
|
332
|
+
parsed = JSON.parse(body);
|
|
333
|
+
} catch {
|
|
334
|
+
throw new Error(`CNN Fear & Greed returned non-JSON data: ${body.slice(0, 120)}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!isObject(parsed)) {
|
|
338
|
+
throw new Error("CNN Fear & Greed response was not an object");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return parsed as CnnFearGreedGraphData;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function fetchFearGreedData(options: {
|
|
345
|
+
date?: Date;
|
|
346
|
+
fetcher?: typeof fetch;
|
|
347
|
+
} = {}): Promise<FearGreedData> {
|
|
348
|
+
const fetcher = options.fetcher ?? fetch;
|
|
349
|
+
const date = options.date ?? new Date();
|
|
350
|
+
const latestUrl = `${CNN_FEAR_GREED_GRAPH_URL}/${formatLocalDate(date)}`;
|
|
351
|
+
|
|
352
|
+
const [chartsResult, latestResult] = await Promise.allSettled([
|
|
353
|
+
fetchCnnGraphData(CNN_FEAR_GREED_GRAPH_URL, fetcher),
|
|
354
|
+
fetchCnnGraphData(latestUrl, fetcher),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
const charts = chartsResult.status === "fulfilled" ? chartsResult.value : null;
|
|
358
|
+
const latest = latestResult.status === "fulfilled" ? latestResult.value : null;
|
|
359
|
+
|
|
360
|
+
if (!charts && !latest) {
|
|
361
|
+
const reason = chartsResult.status === "rejected" ? chartsResult.reason : latestResult.reason;
|
|
362
|
+
throw reason instanceof Error ? reason : new Error(String(reason));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return normalizeFearGreedData(charts ?? latest!, latest ?? charts ?? undefined);
|
|
366
|
+
}
|