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.
Files changed (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. 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 1-${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
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 1-${MAX_TICKER_LIST_SIZE} ticker symbols separated by commas.`,
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 > 0,
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 === 0) return null;
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
- export function CorrelationMatrixPane({ width, height }: PaneProps) {
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.showWidget("debug");
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
- export function EconCalendarPane({ focused, width, height }: PaneProps) {
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
+ }