gloomberb 0.4.1 → 0.5.0

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 (207) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act } from "react";
3
- import { testRender } from "@opentui/react/test-utils";
2
+ import { act, type ReactElement } from "react";
3
+ import { createTestRenderer } from "@opentui/core/testing";
4
+ import { createRoot } from "@opentui/react";
4
5
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
5
6
  import {
6
7
  AppContext,
@@ -23,8 +24,10 @@ import {
23
24
 
24
25
  const TEST_PANE_ID = "comparison-chart:test";
25
26
 
26
- let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
27
+ let testSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;
28
+ let root: ReturnType<typeof createRoot> | undefined;
27
29
  let sharedCoordinator: MarketDataCoordinator | null = null;
30
+ const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
28
31
 
29
32
  function makeTicker(symbol: string, currency: string): TickerRecord {
30
33
  return {
@@ -96,8 +99,16 @@ function createProvider(historyBySymbol: Record<string, number[]>, currencyBySym
96
99
 
97
100
  function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
98
101
  return {
102
+ navigateTickerFn: (symbol: string) => {
103
+ spy.selected.push(symbol);
104
+ spy.focused.push("ticker-detail");
105
+ },
99
106
  selectTickerFn: (symbol: string) => { spy.selected.push(symbol); },
100
107
  focusPaneFn: (paneId: string) => { spy.focused.push(paneId); },
108
+ navigateTickerFn: (symbol: string) => {
109
+ spy.selected.push(symbol);
110
+ spy.focused.push("ticker-detail");
111
+ },
101
112
  } as unknown as PluginRegistry;
102
113
  }
103
114
 
@@ -132,7 +143,7 @@ function createComparisonHarness(
132
143
  focused: boolean;
133
144
  width: number;
134
145
  height: number;
135
- }) => JSX.Element;
146
+ }) => ReactElement;
136
147
 
137
148
  return (
138
149
  <AppContext value={{ state, dispatch: () => {} }}>
@@ -153,12 +164,47 @@ async function flushFrames(count = 3) {
153
164
  for (let index = 0; index < count; index += 1) {
154
165
  await act(async () => {
155
166
  await Promise.resolve();
167
+ await new Promise((resolve) => setTimeout(resolve, 0));
156
168
  await testSetup!.renderOnce();
169
+ await Promise.resolve();
157
170
  });
158
171
  }
159
172
  }
160
173
 
161
- afterEach(() => {
174
+ async function mountComparisonHarness(
175
+ settings: Record<string, unknown>,
176
+ tickers: TickerRecord[],
177
+ financials: Array<[string, TickerFinancials]>,
178
+ ) {
179
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
180
+ testSetup = await createTestRenderer({ width: 120, height: 20 });
181
+ root = createRoot(testSetup.renderer);
182
+ await act(async () => {
183
+ root!.render(
184
+ createComparisonHarness(settings, tickers, financials),
185
+ );
186
+ await Promise.resolve();
187
+ await new Promise((resolve) => setTimeout(resolve, 0));
188
+ await testSetup!.renderOnce();
189
+ });
190
+ }
191
+
192
+ async function pressComparisonInput(action: () => void) {
193
+ await act(async () => {
194
+ action();
195
+ await testSetup!.renderOnce();
196
+ await Promise.resolve();
197
+ });
198
+ }
199
+
200
+ afterEach(async () => {
201
+ if (root) {
202
+ await act(async () => {
203
+ root!.unmount();
204
+ await Promise.resolve();
205
+ });
206
+ root = undefined;
207
+ }
162
208
  if (testSetup) {
163
209
  testSetup.renderer.destroy();
164
210
  testSetup = undefined;
@@ -172,7 +218,7 @@ afterEach(() => {
172
218
  describe("comparisonChartPlugin", () => {
173
219
  test("creates a configured comparison pane with percent as the default axis", () => {
174
220
  const template = comparisonChartPlugin.paneTemplates?.find((entry) => entry.id === COMPARISON_CHART_TEMPLATE_ID);
175
- const paneDef = comparisonChartPlugin.panes.find((entry) => entry.id === COMPARISON_CHART_PANE_ID);
221
+ const paneDef = comparisonChartPlugin.panes?.find((entry) => entry.id === COMPARISON_CHART_PANE_ID);
176
222
 
177
223
  expect(template).toBeDefined();
178
224
  expect(paneDef?.defaultMode).toBe("floating");
@@ -223,30 +269,32 @@ describe("comparisonChartPlugin", () => {
223
269
  setSharedMarketDataCoordinator(sharedCoordinator);
224
270
  setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
225
271
 
226
- testSetup = await testRender(
227
- createComparisonHarness({
228
- axisMode: "price",
229
- symbols: ["AAPL", "7203"],
230
- symbolsText: "AAPL, 7203",
231
- }, [
232
- makeTicker("AAPL", "USD"),
233
- makeTicker("7203", "JPY"),
234
- ], [
235
- ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104, 106])],
236
- ["7203", makeFinancials("7203", "JPY", [2000, 2020, 2050, 2100])],
237
- ]),
238
- { width: 120, height: 20 },
239
- );
272
+ await mountComparisonHarness({
273
+ axisMode: "price",
274
+ symbols: ["AAPL", "7203"],
275
+ symbolsText: "AAPL, 7203",
276
+ }, [
277
+ makeTicker("AAPL", "USD"),
278
+ makeTicker("7203", "JPY"),
279
+ ], [
280
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104, 106])],
281
+ ["7203", makeFinancials("7203", "JPY", [2000, 2020, 2050, 2100])],
282
+ ]);
240
283
 
241
284
  await flushFrames();
242
285
 
243
- const frame = testSetup.captureCharFrame();
286
+ const frame = testSetup!.captureCharFrame();
244
287
  expect(frame).toContain("Mixed currencies detected; showing percent change.");
245
288
  expect(frame).toContain("1:1D");
246
289
  expect(frame).toContain("2:1W");
247
290
  expect(frame).toContain("1D");
248
291
  expect(frame).toContain("view:");
249
- expect(frame).toContain("arrows legend");
292
+ expect(frame).toContain("[m]ode");
293
+ expect(frame).toContain("[r]es");
294
+ expect(frame).toContain("[up/down]legend");
295
+ expect(frame).not.toContain("arrows legend");
296
+ expect(frame).not.toContain("wheel pan");
297
+ expect(frame).not.toContain("wheel zoom");
250
298
  expect(frame).not.toContain("side by side");
251
299
  });
252
300
 
@@ -263,24 +311,21 @@ describe("comparisonChartPlugin", () => {
263
311
  setSharedMarketDataCoordinator(sharedCoordinator);
264
312
  setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
265
313
 
266
- testSetup = await testRender(
267
- createComparisonHarness({
268
- axisMode: "price",
269
- symbols: ["AAPL", "MSFT"],
270
- symbolsText: "AAPL, MSFT",
271
- }, [
272
- makeTicker("AAPL", "USD"),
273
- makeTicker("MSFT", "USD"),
274
- ], [
275
- ["AAPL", makeFinancials("AAPL", "USD", [])],
276
- ["MSFT", makeFinancials("MSFT", "USD", [])],
277
- ]),
278
- { width: 120, height: 20 },
279
- );
314
+ await mountComparisonHarness({
315
+ axisMode: "price",
316
+ symbols: ["AAPL", "MSFT"],
317
+ symbolsText: "AAPL, MSFT",
318
+ }, [
319
+ makeTicker("AAPL", "USD"),
320
+ makeTicker("MSFT", "USD"),
321
+ ], [
322
+ ["AAPL", makeFinancials("AAPL", "USD", [])],
323
+ ["MSFT", makeFinancials("MSFT", "USD", [])],
324
+ ]);
280
325
 
281
326
  await flushFrames();
282
327
 
283
- const frame = testSetup.captureCharFrame();
328
+ const frame = testSetup!.captureCharFrame();
284
329
  expect(frame).toContain("No chart data yet.");
285
330
  expect(frame).toContain("1:1D");
286
331
  expect(frame).toContain("2:1W");
@@ -303,28 +348,23 @@ describe("comparisonChartPlugin", () => {
303
348
  setSharedMarketDataCoordinator(sharedCoordinator);
304
349
  setSharedRegistryForTests(createRegistrySpy(spy));
305
350
 
306
- testSetup = await testRender(
307
- createComparisonHarness({
308
- axisMode: "percent",
309
- symbols: ["AAPL", "MSFT", "NVDA"],
310
- symbolsText: "AAPL, MSFT, NVDA",
311
- }, [
312
- makeTicker("AAPL", "USD"),
313
- makeTicker("MSFT", "USD"),
314
- makeTicker("NVDA", "USD"),
315
- ], [
316
- ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
317
- ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
318
- ["NVDA", makeFinancials("NVDA", "USD", [300, 305, 310])],
319
- ]),
320
- { width: 120, height: 20 },
321
- );
351
+ await mountComparisonHarness({
352
+ axisMode: "percent",
353
+ symbols: ["AAPL", "MSFT", "NVDA"],
354
+ symbolsText: "AAPL, MSFT, NVDA",
355
+ }, [
356
+ makeTicker("AAPL", "USD"),
357
+ makeTicker("MSFT", "USD"),
358
+ makeTicker("NVDA", "USD"),
359
+ ], [
360
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
361
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
362
+ ["NVDA", makeFinancials("NVDA", "USD", [300, 305, 310])],
363
+ ]);
322
364
 
323
365
  await flushFrames();
324
- testSetup.mockInput.pressArrow("right");
325
- await flushFrames();
326
- testSetup.mockInput.pressEnter();
327
- await flushFrames();
366
+ await pressComparisonInput(() => testSetup!.mockInput.pressArrow("right"));
367
+ await pressComparisonInput(() => testSetup!.mockInput.pressEnter());
328
368
 
329
369
  expect(spy.selected).toEqual(["MSFT"]);
330
370
  expect(spy.focused).toEqual(["ticker-detail"]);
@@ -106,8 +106,7 @@ function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
106
106
  const settings = useMemo(() => getComparisonChartPaneSettings(pane?.settings), [pane?.settings]);
107
107
 
108
108
  const openTicker = useCallback((symbol: string) => {
109
- registry?.selectTickerFn(symbol);
110
- registry?.focusPaneFn("ticker-detail");
109
+ registry?.navigateTickerFn(symbol);
111
110
  }, [registry]);
112
111
 
113
112
  if (settings.symbols.length === 0) {
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ alignDatedReturns,
4
+ computeDatedReturns,
5
+ computeReturns,
6
+ correlateDatedReturns,
7
+ formatCorrelation,
8
+ pearsonCorrelation,
9
+ } from "./compute";
10
+
11
+ describe("computeReturns", () => {
12
+ test("computes simple returns", () => {
13
+ const returns = computeReturns([100, 110, 105, 115]);
14
+ expect(returns).toHaveLength(3);
15
+ expect(returns[0]).toBeCloseTo(0.1, 5);
16
+ expect(returns[1]).toBeCloseTo(-0.0455, 3);
17
+ expect(returns[2]).toBeCloseTo(0.0952, 3);
18
+ });
19
+
20
+ test("returns empty for single value", () => {
21
+ expect(computeReturns([100])).toEqual([]);
22
+ });
23
+
24
+ test("skips zero or invalid previous closes", () => {
25
+ expect(computeReturns([0, 10, 20, Number.NaN, 30])).toEqual([1]);
26
+ });
27
+ });
28
+
29
+ describe("computeDatedReturns", () => {
30
+ test("keys returns by the current close date", () => {
31
+ const returns = computeDatedReturns([
32
+ { date: new Date("2024-01-01T00:00:00Z"), close: 100 },
33
+ { date: new Date("2024-01-02T00:00:00Z"), close: 110 },
34
+ { date: new Date("2024-01-03T00:00:00Z"), close: 121 },
35
+ ]);
36
+
37
+ expect(returns).toEqual([
38
+ { dateKey: "2024-01-02", value: 0.1 },
39
+ { dateKey: "2024-01-03", value: 0.1 },
40
+ ]);
41
+ });
42
+
43
+ test("sorts points before computing returns", () => {
44
+ const returns = computeDatedReturns([
45
+ { date: new Date("2024-01-03T00:00:00Z"), close: 121 },
46
+ { date: new Date("2024-01-01T00:00:00Z"), close: 100 },
47
+ { date: new Date("2024-01-02T00:00:00Z"), close: 110 },
48
+ ]);
49
+
50
+ expect(returns.map((entry) => entry.dateKey)).toEqual(["2024-01-02", "2024-01-03"]);
51
+ });
52
+ });
53
+
54
+ describe("alignDatedReturns", () => {
55
+ test("aligns series by shared date keys", () => {
56
+ const aligned = alignDatedReturns(
57
+ [
58
+ { dateKey: "2024-01-02", value: 1 },
59
+ { dateKey: "2024-01-03", value: 2 },
60
+ { dateKey: "2024-01-04", value: 3 },
61
+ ],
62
+ [
63
+ { dateKey: "2024-01-01", value: 99 },
64
+ { dateKey: "2024-01-02", value: 10 },
65
+ { dateKey: "2024-01-04", value: 30 },
66
+ ],
67
+ );
68
+
69
+ expect(aligned).toEqual({
70
+ x: [1, 3],
71
+ y: [10, 30],
72
+ sampleSize: 2,
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("pearsonCorrelation", () => {
78
+ test("perfect positive correlation", () => {
79
+ const x = [1, 2, 3, 4, 5];
80
+ const y = [2, 4, 6, 8, 10];
81
+ expect(pearsonCorrelation(x, y)).toBeCloseTo(1.0, 5);
82
+ });
83
+
84
+ test("perfect negative correlation", () => {
85
+ const x = [1, 2, 3, 4, 5];
86
+ const y = [10, 8, 6, 4, 2];
87
+ expect(pearsonCorrelation(x, y)).toBeCloseTo(-1.0, 5);
88
+ });
89
+
90
+ test("returns null for insufficient data", () => {
91
+ expect(pearsonCorrelation([1, 2], [3, 4])).toBeNull();
92
+ });
93
+
94
+ test("returns null for zero variance", () => {
95
+ expect(pearsonCorrelation([5, 5, 5, 5, 5], [1, 2, 3, 4, 5])).toBeNull();
96
+ });
97
+ });
98
+
99
+ describe("correlateDatedReturns", () => {
100
+ test("correlates only shared trading dates", () => {
101
+ const result = correlateDatedReturns(
102
+ [
103
+ { dateKey: "2024-01-02", value: 1 },
104
+ { dateKey: "2024-01-03", value: 2 },
105
+ { dateKey: "2024-01-04", value: 3 },
106
+ { dateKey: "2024-01-05", value: 4 },
107
+ { dateKey: "2024-01-06", value: 5 },
108
+ ],
109
+ [
110
+ { dateKey: "2024-01-01", value: 999 },
111
+ { dateKey: "2024-01-02", value: 2 },
112
+ { dateKey: "2024-01-03", value: 4 },
113
+ { dateKey: "2024-01-04", value: 6 },
114
+ { dateKey: "2024-01-05", value: 8 },
115
+ { dateKey: "2024-01-06", value: 10 },
116
+ ],
117
+ );
118
+
119
+ expect(result.sampleSize).toBe(5);
120
+ expect(result.correlation).toBeCloseTo(1, 5);
121
+ });
122
+
123
+ test("returns sample size even when shared observations are insufficient", () => {
124
+ expect(correlateDatedReturns(
125
+ [
126
+ { dateKey: "2024-01-02", value: 1 },
127
+ { dateKey: "2024-01-03", value: 2 },
128
+ ],
129
+ [
130
+ { dateKey: "2024-01-02", value: 1 },
131
+ { dateKey: "2024-01-03", value: 2 },
132
+ ],
133
+ )).toEqual({ correlation: null, sampleSize: 2 });
134
+ });
135
+ });
136
+
137
+ describe("formatCorrelation", () => {
138
+ test("formats positive", () => {
139
+ expect(formatCorrelation(0.85)).toContain("0.85");
140
+ });
141
+ test("formats negative", () => {
142
+ expect(formatCorrelation(-0.42)).toContain("-0.42");
143
+ });
144
+ test("formats null as dash", () => {
145
+ expect(formatCorrelation(null)).toContain("—");
146
+ });
147
+ });
@@ -0,0 +1,115 @@
1
+ import type { PricePoint } from "../../../types/financials";
2
+
3
+ export interface DatedReturn {
4
+ dateKey: string;
5
+ value: number;
6
+ }
7
+
8
+ export interface CorrelationResult {
9
+ correlation: number | null;
10
+ sampleSize: number;
11
+ }
12
+
13
+ function getPointTimestamp(point: PricePoint): number {
14
+ const value = point.date as Date | string | number | null | undefined;
15
+ if (value instanceof Date) return value.getTime();
16
+ if (value == null) return Number.NaN;
17
+ return new Date(value).getTime();
18
+ }
19
+
20
+ function toDateKey(point: PricePoint): string | null {
21
+ const timestamp = getPointTimestamp(point);
22
+ if (!Number.isFinite(timestamp)) return null;
23
+ return new Date(timestamp).toISOString().slice(0, 10);
24
+ }
25
+
26
+ function comparePricePointsByDate(left: PricePoint, right: PricePoint): number {
27
+ return getPointTimestamp(left) - getPointTimestamp(right);
28
+ }
29
+
30
+ export function computeReturns(closes: number[]): number[] {
31
+ const returns: number[] = [];
32
+ for (let i = 1; i < closes.length; i++) {
33
+ const previous = closes[i - 1]!;
34
+ const current = closes[i]!;
35
+ if (!Number.isFinite(previous) || !Number.isFinite(current) || previous === 0) continue;
36
+ returns.push((current - previous) / previous);
37
+ }
38
+ return returns;
39
+ }
40
+
41
+ export function computeDatedReturns(points: PricePoint[]): DatedReturn[] {
42
+ const sorted = [...points]
43
+ .filter((point) => Number.isFinite(getPointTimestamp(point)) && Number.isFinite(point.close))
44
+ .sort(comparePricePointsByDate);
45
+ const byDate = new Map<string, number>();
46
+
47
+ for (let i = 1; i < sorted.length; i++) {
48
+ const previous = sorted[i - 1]!;
49
+ const current = sorted[i]!;
50
+ if (previous.close === 0) continue;
51
+ const value = (current.close - previous.close) / previous.close;
52
+ const dateKey = toDateKey(current);
53
+ if (!dateKey || !Number.isFinite(value)) continue;
54
+ byDate.set(dateKey, value);
55
+ }
56
+
57
+ return [...byDate.entries()].map(([dateKey, value]) => ({ dateKey, value }));
58
+ }
59
+
60
+ export function alignDatedReturns(x: DatedReturn[], y: DatedReturn[]): { x: number[]; y: number[]; sampleSize: number } {
61
+ const yByDate = new Map(y.map((entry) => [entry.dateKey, entry.value] as const));
62
+ const alignedX: number[] = [];
63
+ const alignedY: number[] = [];
64
+
65
+ for (const left of x) {
66
+ const rightValue = yByDate.get(left.dateKey);
67
+ if (rightValue === undefined) continue;
68
+ alignedX.push(left.value);
69
+ alignedY.push(rightValue);
70
+ }
71
+
72
+ return {
73
+ x: alignedX,
74
+ y: alignedY,
75
+ sampleSize: alignedX.length,
76
+ };
77
+ }
78
+
79
+ export function pearsonCorrelation(x: number[], y: number[], minObservations = 5): number | null {
80
+ const n = Math.min(x.length, y.length);
81
+ if (n < minObservations) return null;
82
+
83
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
84
+ for (let i = 0; i < n; i++) {
85
+ sumX += x[i]!;
86
+ sumY += y[i]!;
87
+ sumXY += x[i]! * y[i]!;
88
+ sumX2 += x[i]! * x[i]!;
89
+ sumY2 += y[i]! * y[i]!;
90
+ }
91
+
92
+ const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
93
+ if (denom === 0) return null;
94
+ return (n * sumXY - sumX * sumY) / denom;
95
+ }
96
+
97
+ export function correlateDatedReturns(x: DatedReturn[], y: DatedReturn[], minObservations = 5): CorrelationResult {
98
+ const aligned = alignDatedReturns(x, y);
99
+ return {
100
+ correlation: pearsonCorrelation(aligned.x, aligned.y, minObservations),
101
+ sampleSize: aligned.sampleSize,
102
+ };
103
+ }
104
+
105
+ export function formatCorrelation(r: number | null): string {
106
+ if (r === null) return " — ";
107
+ return r >= 0 ? ` ${r.toFixed(2)}` : r.toFixed(2);
108
+ }
109
+
110
+ export function correlationColor(r: number | null, positive: string, negative: string, neutral: string): string {
111
+ if (r === null) return neutral;
112
+ if (r > 0.7) return positive;
113
+ if (r < -0.7) return negative;
114
+ return neutral;
115
+ }