gloomberb 0.9.2 → 0.9.4

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 (233) hide show
  1. package/README.md +24 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app/runtime/desktop-menu.ts +5 -1
  5. package/src/app/runtime/ticker-refresh.ts +4 -1
  6. package/src/app.tsx +20 -8
  7. package/src/brokers/account-cache.ts +1 -1
  8. package/src/brokers/sync-broker-instance.test.ts +107 -0
  9. package/src/brokers/sync-broker-instance.ts +22 -10
  10. package/src/capabilities/registry.ts +1 -0
  11. package/src/capabilities/types.ts +23 -0
  12. package/src/cli/commands/api.ts +119 -0
  13. package/src/cli/commands/automation.ts +173 -0
  14. package/src/cli/commands/command-utils.ts +53 -0
  15. package/src/cli/commands/market.ts +527 -0
  16. package/src/cli/commands/overview.ts +222 -0
  17. package/src/cli/commands/search.ts +39 -0
  18. package/src/cli/commands/system.ts +553 -0
  19. package/src/cli/commands/ticker.ts +104 -12
  20. package/src/cli/context.ts +35 -2
  21. package/src/cli/desktop-pane-shot.ts +2 -1
  22. package/src/cli/entry.ts +51 -0
  23. package/src/cli/errors.ts +56 -5
  24. package/src/cli/index.ts +73 -11
  25. package/src/cli/options.test.ts +38 -0
  26. package/src/cli/options.ts +95 -0
  27. package/src/cli/pane-functions/index.ts +10 -1
  28. package/src/cli/pane-functions/screenshot.ts +13 -1
  29. package/src/cli/registry.test.ts +40 -0
  30. package/src/cli/registry.ts +46 -5
  31. package/src/cli/result.test.ts +82 -0
  32. package/src/cli/result.ts +149 -0
  33. package/src/cli/types.ts +11 -0
  34. package/src/cli.test.ts +11 -11
  35. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  36. package/src/components/chart/bar-chart-renderer.ts +226 -27
  37. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  38. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  39. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  40. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  41. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  42. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  43. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  44. package/src/components/chart/core/pane-settings.test.ts +27 -0
  45. package/src/components/chart/core/pane-settings.ts +60 -1
  46. package/src/components/chart/core/pointer.ts +34 -6
  47. package/src/components/chart/price-axis-labels.tsx +3 -1
  48. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  49. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  50. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  51. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  52. package/src/components/chart/static/chart/surface.tsx +167 -24
  53. package/src/components/chart/stock-chart/index.test.ts +25 -0
  54. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  55. package/src/components/chart/stock-chart/settings.ts +7 -3
  56. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  57. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  58. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  59. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  60. package/src/components/data-table/view.tsx +13 -2
  61. package/src/components/feed-data-table/stack-view.tsx +9 -1
  62. package/src/components/index.ts +1 -1
  63. package/src/components/input-search-bar.test.tsx +103 -0
  64. package/src/components/input-search-bar.tsx +16 -0
  65. package/src/components/layout/detached-pane-shell.tsx +6 -3
  66. package/src/components/layout/floating-pane.tsx +3 -2
  67. package/src/components/layout/pane/content.tsx +20 -8
  68. package/src/components/layout/pane/footer/registration.tsx +6 -2
  69. package/src/components/layout/pane/frame.tsx +4 -4
  70. package/src/components/layout/pane/header.tsx +40 -4
  71. package/src/components/layout/pane/index.tsx +3 -1
  72. package/src/components/layout/pane/sizing.test.ts +43 -0
  73. package/src/components/layout/pane/sizing.ts +13 -4
  74. package/src/components/layout/shell/active-drag.ts +5 -1
  75. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  76. package/src/components/layout/shell/drag/runtime.ts +28 -3
  77. package/src/components/layout/shell/fullscreen.ts +10 -0
  78. package/src/components/layout/shell/index.test.tsx +487 -4
  79. package/src/components/layout/shell/index.tsx +165 -4
  80. package/src/components/layout/shell/layout-state.test.ts +33 -0
  81. package/src/components/layout/shell/layout-state.ts +4 -9
  82. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  83. package/src/components/layout/shell/pane/layers.tsx +34 -16
  84. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  85. package/src/components/layout/shell/shortcuts.ts +4 -0
  86. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  87. package/src/components/layout/shell/visible-layout.ts +17 -0
  88. package/src/components/layout/shell/window-mode/index.ts +11 -3
  89. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  90. package/src/components/layout/status-bar.test.tsx +111 -1
  91. package/src/components/layout/status-bar.tsx +37 -7
  92. package/src/components/layout/transient-layout.tsx +57 -0
  93. package/src/components/layout/window-edit/mode.test.ts +76 -5
  94. package/src/components/layout/window-edit/mode.ts +47 -2
  95. package/src/components/layout/window-edit/presentation.ts +2 -1
  96. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  97. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  98. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  99. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  100. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  101. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  102. package/src/components/price-performance.tsx +9 -1
  103. package/src/components/table-view-shared.tsx +12 -4
  104. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  105. package/src/components/ui/data-table/types.ts +4 -0
  106. package/src/components/ui/fields.tsx +10 -3
  107. package/src/components/ui/page-stack-view.tsx +6 -2
  108. package/src/components/ui/table-layout.test.ts +25 -0
  109. package/src/components/ui/table-layout.ts +22 -6
  110. package/src/components/ui/tabs.tsx +26 -10
  111. package/src/components/ui/ui.test.tsx +87 -0
  112. package/src/core/app-services.ts +7 -4
  113. package/src/core/state/app/layout-reducer.ts +2 -2
  114. package/src/data/config/store/index.ts +1 -2
  115. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  116. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  117. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  118. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  119. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  120. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  121. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  122. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  123. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  124. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  125. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  126. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  127. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  128. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  129. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  130. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  131. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  132. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  133. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  134. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  135. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  136. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  137. package/src/plugins/builtin/options/view.test.tsx +141 -4
  138. package/src/plugins/builtin/options/view.tsx +76 -15
  139. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  140. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  141. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  142. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  143. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  144. package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  146. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  147. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  148. package/src/plugins/builtin/shared/read-state.ts +43 -0
  149. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  150. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  151. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  152. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  153. package/src/plugins/builtin/substack/pane.tsx +8 -3
  154. package/src/plugins/builtin/substack/read-state.ts +55 -0
  155. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  156. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  157. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  158. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  161. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  162. package/src/plugins/catalog-ui.ts +2 -0
  163. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  164. package/src/plugins/ibkr/config.test.ts +31 -2
  165. package/src/plugins/ibkr/config.ts +9 -2
  166. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  167. package/src/plugins/ibkr/flex/index.ts +30 -0
  168. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  169. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  170. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  171. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  172. package/src/plugins/pane-manager/docking.ts +9 -2
  173. package/src/plugins/pane-manager/gridlock.ts +13 -5
  174. package/src/plugins/pane-manager/layout-state.ts +25 -0
  175. package/src/plugins/pane-manager/queries.ts +8 -2
  176. package/src/plugins/pane-manager.test.ts +33 -0
  177. package/src/plugins/pane-manager.ts +6 -1
  178. package/src/plugins/prediction-markets/chart.tsx +10 -4
  179. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  180. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  181. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  182. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  183. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  184. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  185. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  186. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  187. package/src/plugins/prediction-markets/pane.test.tsx +75 -0
  188. package/src/plugins/prediction-markets/pane.tsx +71 -23
  189. package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
  190. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  191. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  192. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  193. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  194. package/src/renderers/electrobun/bun/index.ts +13 -0
  195. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  196. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  197. package/src/renderers/electrobun/view/app-services.ts +6 -4
  198. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  199. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  200. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  201. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  202. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  203. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  204. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  205. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  206. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  207. package/src/renderers/electrobun/view/styles.css +11 -0
  208. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  209. package/src/renderers/opentui/host.tsx +17 -1
  210. package/src/renderers/opentui/start.tsx +13 -5
  211. package/src/renderers/opentui/ui-host.tsx +57 -25
  212. package/src/sources/provider-router/batches.ts +16 -2
  213. package/src/sources/provider-router/financial-routes.ts +5 -0
  214. package/src/sources/provider-router/financials.ts +11 -23
  215. package/src/sources/provider-router/index.test.ts +189 -0
  216. package/src/sources/provider-router/primary.ts +3 -2
  217. package/src/sources/sec-edgar.test.ts +83 -0
  218. package/src/sources/sec-edgar.ts +231 -0
  219. package/src/sources/yahoo-finance.test.ts +51 -0
  220. package/src/sources/yahoo-finance.ts +49 -1
  221. package/src/state/app/context/index.test.tsx +5 -8
  222. package/src/state/app/input-capture.ts +12 -0
  223. package/src/types/broker.ts +1 -0
  224. package/src/types/config.ts +10 -0
  225. package/src/types/plugin.ts +28 -0
  226. package/src/types/trading.ts +2 -0
  227. package/src/ui/host.tsx +16 -0
  228. package/src/ui/index.tsx +2 -0
  229. package/src/utils/cli-output.ts +6 -0
  230. package/src/utils/financial-statements.ts +23 -0
  231. package/src/utils/search-focus-navigation.ts +24 -0
  232. package/src/version.ts +1 -1
  233. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -6,6 +6,7 @@ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../../
6
6
  import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane/footer";
7
7
  import {
8
8
  AppContext,
9
+ type AppAction,
9
10
  createInitialState,
10
11
  PaneInstanceProvider,
11
12
  } from "../../../state/app/context";
@@ -13,7 +14,7 @@ import { Box } from "../../../ui";
13
14
  import { cloneLayout, createDefaultConfig } from "../../../types/config";
14
15
  import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
15
16
  import type { DataProvider } from "../../../types/data-provider";
16
- import type { TickerFinancials } from "../../../types/financials";
17
+ import type { PricePoint, TickerFinancials } from "../../../types/financials";
17
18
  import type { PluginRegistry } from "../../registry";
18
19
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../../runtime";
19
20
  import { setSharedMarketDataForTests, setSharedRegistryForTests } from "../../registry";
@@ -30,6 +31,12 @@ let root: ReturnType<typeof createRoot> | undefined;
30
31
  let sharedCoordinator: MarketDataCoordinator | null = null;
31
32
  const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
32
33
 
34
+ interface ComparisonHarnessOptions {
35
+ dispatch?: (action: AppAction) => void;
36
+ focused?: boolean;
37
+ focusedPaneId?: string | null;
38
+ }
39
+
33
40
  function makeTicker(symbol: string, currency: string): TickerRecord {
34
41
  return {
35
42
  metadata: {
@@ -90,6 +97,13 @@ function makeDatedFinancials(symbol: string, currency: string, closes: Array<[st
90
97
  };
91
98
  }
92
99
 
100
+ function makeDatedPriceHistory(closes: Array<[string, number]>): PricePoint[] {
101
+ return closes.map(([date, close]) => ({
102
+ date: new Date(`${date}T00:00:00Z`),
103
+ close,
104
+ }));
105
+ }
106
+
93
107
  function createProvider(historyBySymbol: Record<string, number[]>, currencyBySymbol: Record<string, string>): DataProvider {
94
108
  return {
95
109
  id: "test-provider",
@@ -139,6 +153,30 @@ function createDatedProvider(financialsBySymbol: Record<string, TickerFinancials
139
153
  return provider;
140
154
  }
141
155
 
156
+ function createRangeAwareDatedProvider({
157
+ fullHistoryBySymbol,
158
+ shortHistoryBySymbol,
159
+ }: {
160
+ fullHistoryBySymbol: Record<string, Array<[string, number]>>;
161
+ shortHistoryBySymbol: Record<string, Array<[string, number]>>;
162
+ }): DataProvider {
163
+ const financialsBySymbol = Object.fromEntries(
164
+ Object.entries(shortHistoryBySymbol).map(([symbol, history]) => [
165
+ symbol,
166
+ makeDatedFinancials(symbol, "USD", history),
167
+ ]),
168
+ );
169
+ const provider = createDatedProvider(financialsBySymbol);
170
+ const resolveHistory = (symbol: string, range: string) => makeDatedPriceHistory(
171
+ range === "5Y"
172
+ ? fullHistoryBySymbol[symbol] ?? shortHistoryBySymbol[symbol] ?? []
173
+ : shortHistoryBySymbol[symbol] ?? [],
174
+ );
175
+ provider.getPriceHistory = async (symbol, _exchange, range) => resolveHistory(symbol, range);
176
+ provider.getPriceHistoryForResolution = async (symbol, _exchange, range) => resolveHistory(symbol, range);
177
+ return provider;
178
+ }
179
+
142
180
  function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
143
181
  return {
144
182
  selectTicker: (symbol: string) => { spy.selected.push(symbol); },
@@ -171,7 +209,13 @@ function createComparisonHarness(
171
209
  tickers: TickerRecord[],
172
210
  financials: Array<[string, TickerFinancials]>,
173
211
  runtime: PluginRuntimeAccess,
212
+ options: ComparisonHarnessOptions = {},
174
213
  ) {
214
+ const {
215
+ dispatch = () => {},
216
+ focused = true,
217
+ focusedPaneId = focused ? TEST_PANE_ID : null,
218
+ } = options;
175
219
  const config = createDefaultConfig("/tmp/gloomberb-compare");
176
220
  const layout = {
177
221
  dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
@@ -189,7 +233,7 @@ function createComparisonHarness(
189
233
  layouts: [{ name: "Default", layout: cloneLayout(layout) }],
190
234
  };
191
235
  const state = createInitialState(nextConfig);
192
- state.focusedPaneId = TEST_PANE_ID;
236
+ state.focusedPaneId = focusedPaneId;
193
237
  state.tickers = new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker]));
194
238
  state.financials = new Map(financials);
195
239
 
@@ -202,7 +246,7 @@ function createComparisonHarness(
202
246
  }) => ReactElement;
203
247
 
204
248
  return (
205
- <AppContext value={{ state, dispatch: () => {} }}>
249
+ <AppContext value={{ state, dispatch }}>
206
250
  <PaneInstanceProvider paneId={TEST_PANE_ID}>
207
251
  <PluginRenderProvider pluginId="comparison-chart" runtime={runtime}>
208
252
  <PaneFooterProvider>
@@ -211,11 +255,11 @@ function createComparisonHarness(
211
255
  <ComparisonPane
212
256
  paneId={TEST_PANE_ID}
213
257
  paneType="comparison-chart"
214
- focused
258
+ focused={focused}
215
259
  width={120}
216
260
  height={19}
217
261
  />
218
- <PaneFooterBar footer={footer} focused width={120} />
262
+ <PaneFooterBar footer={footer} focused={focused} width={120} />
219
263
  </Box>
220
264
  )}
221
265
  </PaneFooterProvider>
@@ -241,13 +285,14 @@ async function mountComparisonHarness(
241
285
  tickers: TickerRecord[],
242
286
  financials: Array<[string, TickerFinancials]>,
243
287
  spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
288
+ options: ComparisonHarnessOptions = {},
244
289
  ) {
245
290
  actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
246
291
  testSetup = await createTestRenderer({ width: 120, height: 20 });
247
292
  root = createRoot(testSetup.renderer);
248
293
  await act(async () => {
249
294
  root!.render(
250
- createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy)),
295
+ createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy), options),
251
296
  );
252
297
  await Promise.resolve();
253
298
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -399,6 +444,85 @@ describe("comparisonChartPlugin", () => {
399
444
  expect(frame).toMatch(/MSFT\s+0\.00%/);
400
445
  });
401
446
 
447
+ test("focuses the pane when an unfocused comparison plot is clicked", async () => {
448
+ const dispatched: AppAction[] = [];
449
+ const provider = createProvider({
450
+ AAPL: [100, 102, 104],
451
+ MSFT: [200, 202, 204],
452
+ }, {
453
+ AAPL: "USD",
454
+ MSFT: "USD",
455
+ });
456
+ setSharedMarketDataForTests(provider);
457
+ sharedCoordinator = new MarketDataCoordinator(provider);
458
+ setSharedMarketDataCoordinator(sharedCoordinator);
459
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
460
+
461
+ await mountComparisonHarness({
462
+ axisMode: "percent",
463
+ symbols: ["AAPL", "MSFT"],
464
+ symbolsText: "AAPL, MSFT",
465
+ }, [
466
+ makeTicker("AAPL", "USD"),
467
+ makeTicker("MSFT", "USD"),
468
+ ], [
469
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
470
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
471
+ ], { selected: [], focused: [] }, {
472
+ dispatch: (action) => { dispatched.push(action); },
473
+ focused: false,
474
+ focusedPaneId: "ticker-detail:main",
475
+ });
476
+
477
+ await flushFrames();
478
+ await act(async () => {
479
+ await testSetup!.mockMouse.click(20, 8);
480
+ await testSetup!.renderOnce();
481
+ });
482
+
483
+ expect(dispatched).toContainEqual({ type: "FOCUS_PANE", paneId: TEST_PANE_ID });
484
+ });
485
+
486
+ test("keeps fixed return horizons independent from the selected chart range", async () => {
487
+ const shortHistory = {
488
+ AAPL: [["2025-01-02", 180], ["2026-01-02", 200]],
489
+ MSFT: [["2025-01-02", 300], ["2026-01-02", 330]],
490
+ } satisfies Record<string, Array<[string, number]>>;
491
+ const fullHistory = {
492
+ AAPL: [["2021-01-02", 100], ["2023-01-02", 150], ["2025-01-02", 180], ["2026-01-02", 200]],
493
+ MSFT: [["2021-01-02", 220], ["2023-01-02", 270], ["2025-01-02", 300], ["2026-01-02", 330]],
494
+ } satisfies Record<string, Array<[string, number]>>;
495
+ const provider = createRangeAwareDatedProvider({
496
+ fullHistoryBySymbol: fullHistory,
497
+ shortHistoryBySymbol: shortHistory,
498
+ });
499
+ setSharedMarketDataForTests(provider);
500
+ sharedCoordinator = new MarketDataCoordinator(provider);
501
+ setSharedMarketDataCoordinator(sharedCoordinator);
502
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
503
+
504
+ await mountComparisonHarness({
505
+ axisMode: "percent",
506
+ rangePreset: "1Y",
507
+ chartResolution: "1d",
508
+ symbols: ["AAPL", "MSFT"],
509
+ symbolsText: "AAPL, MSFT",
510
+ }, [
511
+ makeTicker("AAPL", "USD"),
512
+ makeTicker("MSFT", "USD"),
513
+ ], [
514
+ ["AAPL", makeDatedFinancials("AAPL", "USD", shortHistory.AAPL)],
515
+ ["MSFT", makeDatedFinancials("MSFT", "USD", shortHistory.MSFT)],
516
+ ]);
517
+
518
+ await flushFrames(8);
519
+
520
+ const frame = testSetup!.captureCharFrame();
521
+ expect(frame).toContain("5Y");
522
+ expect(frame).toMatch(/> AAPL\s+\+11\.11%[^\n]*\+100\.00%/);
523
+ expect(frame).toMatch(/MSFT\s+\+10\.00%[^\n]*\+50\.00%/);
524
+ });
525
+
402
526
  test("sorts comparison summary rows by one-year return", async () => {
403
527
  const rows = {
404
528
  SLOW: makeDatedFinancials("SLOW", "USD", [["2025-01-02", 100], ["2026-01-02", 105]]),
@@ -0,0 +1,3 @@
1
+ export const KELLY_PANE_ID = "kelly-sizer";
2
+ export const COMMON_ASSUMPTIONS_STATE_KEY = "commonAssumptions:v1";
3
+ export const DEFAULT_FLOATING_SIZE = { width: 88, height: 30 };
@@ -0,0 +1,214 @@
1
+ import {
2
+ type KellyCommonAssumptions,
3
+ type KellySizerDraft,
4
+ type KellySizerModeDrafts,
5
+ type KellySizingMode,
6
+ type ScenarioKellyAssumptions,
7
+ } from "./model";
8
+
9
+ export interface InlineField {
10
+ id: string;
11
+ label: string;
12
+ value?: number;
13
+ valueText?: string;
14
+ percent?: boolean;
15
+ suffix?: string;
16
+ allowNegative?: boolean;
17
+ onValue?: (value: number) => void;
18
+ onClear?: () => void;
19
+ onPress?: () => void;
20
+ tone?: "neutral" | "positive" | "negative";
21
+ }
22
+
23
+ export function buildModeFields({
24
+ mode,
25
+ draft,
26
+ updateDraft,
27
+ }: {
28
+ mode: KellySizingMode;
29
+ draft: KellySizerDraft;
30
+ updateDraft: (patch: Partial<KellySizerDraft>) => void;
31
+ }): InlineField[] {
32
+ if (mode === "scenario") {
33
+ const scenario = draft as ScenarioKellyAssumptions;
34
+ const updateOutcome = (index: number, patch: Partial<ScenarioKellyAssumptions["outcomes"][number]>) => {
35
+ updateDraft({
36
+ outcomes: scenario.outcomes.map((outcome, outcomeIndex) => (
37
+ outcomeIndex === index ? { ...outcome, ...patch } : outcome
38
+ )),
39
+ } as Partial<KellySizerDraft>);
40
+ };
41
+ return scenario.outcomes.flatMap((outcome, index): InlineField[] => [
42
+ {
43
+ id: `${outcome.id}:p`,
44
+ label: `${outcome.label} p`,
45
+ value: outcome.probability,
46
+ percent: true,
47
+ onValue: (value) => updateOutcome(index, { probability: value }),
48
+ },
49
+ {
50
+ id: `${outcome.id}:ret`,
51
+ label: `${outcome.label} ret`,
52
+ value: outcome.returnPct,
53
+ percent: true,
54
+ allowNegative: true,
55
+ tone: outcome.returnPct >= 0 ? "positive" : "negative",
56
+ onValue: (value) => updateOutcome(index, { returnPct: value }),
57
+ },
58
+ ]);
59
+ }
60
+
61
+ if (mode === "risk-budget") {
62
+ const risk = draft as KellySizerModeDrafts["risk-budget"];
63
+ return [
64
+ {
65
+ id: "riskBudget",
66
+ label: "Risk budget",
67
+ value: risk.riskBudgetFraction,
68
+ percent: true,
69
+ onValue: (value) => updateDraft({ riskBudgetFraction: value } as Partial<KellySizerDraft>),
70
+ },
71
+ {
72
+ id: "downside",
73
+ label: "Downside",
74
+ value: risk.downsideReturn,
75
+ percent: true,
76
+ allowNegative: true,
77
+ tone: "negative",
78
+ onValue: (value) => updateDraft({ downsideReturn: value } as Partial<KellySizerDraft>),
79
+ },
80
+ {
81
+ id: "winProbability",
82
+ label: "Win p",
83
+ value: risk.winProbability,
84
+ percent: true,
85
+ onValue: (value) => updateDraft({ winProbability: value } as Partial<KellySizerDraft>),
86
+ },
87
+ {
88
+ id: "upside",
89
+ label: "Upside",
90
+ value: risk.upsideReturn,
91
+ percent: true,
92
+ tone: "positive",
93
+ onValue: (value) => updateDraft({ upsideReturn: value } as Partial<KellySizerDraft>),
94
+ },
95
+ ];
96
+ }
97
+
98
+ if (mode === "prediction-market") {
99
+ const market = draft as KellySizerModeDrafts["prediction-market"];
100
+ return [
101
+ {
102
+ id: "side",
103
+ label: "Side",
104
+ valueText: market.side.toUpperCase(),
105
+ tone: "positive",
106
+ onPress: () => updateDraft({ side: market.side === "yes" ? "no" : "yes" } as Partial<KellySizerDraft>),
107
+ },
108
+ {
109
+ id: "estimatedProbability",
110
+ label: "Est p",
111
+ value: market.estimatedProbability,
112
+ percent: true,
113
+ onValue: (value) => updateDraft({ estimatedProbability: value } as Partial<KellySizerDraft>),
114
+ },
115
+ {
116
+ id: "marketPrice",
117
+ label: "Price",
118
+ value: market.marketPrice,
119
+ percent: true,
120
+ onValue: (value) => updateDraft({ marketPrice: value } as Partial<KellySizerDraft>),
121
+ },
122
+ ];
123
+ }
124
+
125
+ if (mode === "asymmetric") {
126
+ const asymmetric = draft as KellySizerModeDrafts["asymmetric"];
127
+ return [
128
+ {
129
+ id: "winProbability",
130
+ label: "Win p",
131
+ value: asymmetric.winProbability,
132
+ percent: true,
133
+ onValue: (value) => updateDraft({ winProbability: value } as Partial<KellySizerDraft>),
134
+ },
135
+ {
136
+ id: "targetReturn",
137
+ label: "Target",
138
+ value: asymmetric.targetReturn,
139
+ percent: true,
140
+ tone: "positive",
141
+ onValue: (value) => updateDraft({ targetReturn: value } as Partial<KellySizerDraft>),
142
+ },
143
+ {
144
+ id: "maxLossReturn",
145
+ label: "Max loss",
146
+ value: asymmetric.maxLossReturn,
147
+ percent: true,
148
+ allowNegative: true,
149
+ tone: "negative",
150
+ onValue: (value) => updateDraft({ maxLossReturn: value } as Partial<KellySizerDraft>),
151
+ },
152
+ ];
153
+ }
154
+
155
+ const binary = draft as KellySizerModeDrafts["binary"];
156
+ return [
157
+ {
158
+ id: "winProbability",
159
+ label: "Win p",
160
+ value: binary.winProbability,
161
+ percent: true,
162
+ onValue: (value) => updateDraft({ winProbability: value } as Partial<KellySizerDraft>),
163
+ },
164
+ {
165
+ id: "upside",
166
+ label: "Upside",
167
+ value: binary.upsideReturn,
168
+ percent: true,
169
+ tone: "positive",
170
+ onValue: (value) => updateDraft({ upsideReturn: value } as Partial<KellySizerDraft>),
171
+ },
172
+ {
173
+ id: "downside",
174
+ label: "Downside",
175
+ value: binary.downsideReturn,
176
+ percent: true,
177
+ allowNegative: true,
178
+ tone: "negative",
179
+ onValue: (value) => updateDraft({ downsideReturn: value } as Partial<KellySizerDraft>),
180
+ },
181
+ ];
182
+ }
183
+
184
+ export function buildCommonFields({
185
+ common,
186
+ updateCommon,
187
+ }: {
188
+ common: KellyCommonAssumptions;
189
+ updateCommon: (patch: Partial<KellyCommonAssumptions>) => void;
190
+ }): InlineField[] {
191
+ return [
192
+ {
193
+ id: "common:kellyFraction",
194
+ label: "Kelly",
195
+ value: common.kellyFraction,
196
+ percent: true,
197
+ onValue: (value) => updateCommon({ kellyFraction: value }),
198
+ },
199
+ {
200
+ id: "common:maxLoss",
201
+ label: "Loss cap",
202
+ value: common.maxLossFraction,
203
+ percent: true,
204
+ onValue: (value) => updateCommon({ maxLossFraction: value }),
205
+ },
206
+ {
207
+ id: "common:maxName",
208
+ label: "Name cap",
209
+ value: common.maxNameFraction,
210
+ percent: true,
211
+ onValue: (value) => updateCommon({ maxNameFraction: value }),
212
+ },
213
+ ];
214
+ }
@@ -0,0 +1,197 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { testRender } from "../../../renderers/opentui/test-utils";
4
+ import { setSharedMarketDataCoordinator } from "../../../market-data/coordinator";
5
+ import type { TickerRecord } from "../../../types/ticker";
6
+ import { KellySizerHarness, createFinancials, createSizerConfig, createTicker } from "./test-support";
7
+
8
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
9
+
10
+ async function renderPane(props?: Parameters<typeof KellySizerHarness>[0]) {
11
+ await act(async () => {
12
+ testSetup = await testRender(<KellySizerHarness {...props} />, { width: 100, height: 30 });
13
+ await Promise.resolve();
14
+ await testSetup.renderOnce();
15
+ });
16
+ }
17
+
18
+ async function flushFrame() {
19
+ await act(async () => {
20
+ await testSetup!.renderOnce();
21
+ });
22
+ }
23
+
24
+ beforeEach(() => {
25
+ setSharedMarketDataCoordinator(null);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ if (testSetup) {
30
+ await act(async () => {
31
+ testSetup!.renderer.destroy();
32
+ });
33
+ testSetup = undefined;
34
+ }
35
+ setSharedMarketDataCoordinator(null);
36
+ });
37
+
38
+ describe("KellySizerPane", () => {
39
+ test("renders binary sizing context, result, and curve", async () => {
40
+ await renderPane();
41
+ await flushFrame();
42
+
43
+ const frame = testSetup!.captureCharFrame();
44
+ expect(frame).toContain("AAPL · Main Portfolio");
45
+ expect(frame).toContain("Bankroll");
46
+ expect(frame).toContain("100000");
47
+ expect(frame).toContain("Win p");
48
+ expect(frame).toContain("Full Kelly");
49
+ expect(frame).toContain("Clipped");
50
+ expect(frame).toContain("Target val");
51
+ expect(frame).toContain("Current %");
52
+ expect(frame).toContain("Kelly Curve");
53
+ expect(frame).toContain("4.0% -> 8.0%");
54
+ expect(frame).toContain("growth");
55
+ expect(frame).toContain("loss cap");
56
+ expect(frame).toContain("cap max loss, max name");
57
+ expect(frame).toContain("full");
58
+ expect(frame).toContain("159%");
59
+ expect(frame).toContain("636%");
60
+ expect(frame).not.toContain("Mode");
61
+ expect(frame).not.toContain("Sensitivity");
62
+ });
63
+
64
+ test("prefills shared caps from plugin config", async () => {
65
+ const config = {
66
+ ...createSizerConfig(),
67
+ pluginConfig: {
68
+ "kelly-sizer": {
69
+ "commonAssumptions:v1": {
70
+ kellyFraction: 0.5,
71
+ maxLossFraction: 0.02,
72
+ maxNameFraction: 0.1,
73
+ },
74
+ },
75
+ },
76
+ };
77
+
78
+ await renderPane({ config, paneState: { mode: "scenario" } });
79
+ await flushFrame();
80
+
81
+ const frame = testSetup!.captureCharFrame();
82
+ expect(frame).toContain("Scenario");
83
+ expect(frame).toMatch(/Kelly\s+50\.0\s+%/);
84
+ expect(frame).toMatch(/Loss cap\s+2\.00\s+%/);
85
+ expect(frame).toMatch(/Name cap\s+10\.0\s+%/);
86
+ });
87
+
88
+ test("keeps shared caps scoped to pane state after seeding", async () => {
89
+ const config = {
90
+ ...createSizerConfig(),
91
+ pluginConfig: {
92
+ "kelly-sizer": {
93
+ "commonAssumptions:v1": {
94
+ kellyFraction: 0.5,
95
+ maxLossFraction: 0.02,
96
+ maxNameFraction: 0.1,
97
+ },
98
+ },
99
+ },
100
+ };
101
+
102
+ await renderPane({
103
+ config,
104
+ paneState: {
105
+ mode: "scenario",
106
+ "commonAssumptions:v1": {
107
+ kellyFraction: 0.3,
108
+ maxLossFraction: 0.015,
109
+ maxNameFraction: 0.07,
110
+ },
111
+ },
112
+ });
113
+ await flushFrame();
114
+
115
+ const frame = testSetup!.captureCharFrame();
116
+ expect(frame).toContain("Scenario");
117
+ expect(frame).toMatch(/Kelly\s+30\.0\s+%/);
118
+ expect(frame).toMatch(/Loss cap\s+1\.50\s+%/);
119
+ expect(frame).toMatch(/Name cap\s+7\.00\s+%/);
120
+ expect(frame).not.toMatch(/Kelly\s+50\.0\s+%/);
121
+ });
122
+
123
+ test("converts foreign quote current value to base currency", async () => {
124
+ const ticker = createTicker({
125
+ symbol: "SIVE",
126
+ currency: "SEK",
127
+ positions: [{
128
+ portfolio: "main",
129
+ shares: 100,
130
+ avgCost: 50,
131
+ currency: "SEK",
132
+ broker: "ibkr",
133
+ brokerInstanceId: "ibkr-flex",
134
+ brokerAccountId: "DU12345",
135
+ marketValue: 10_000,
136
+ unrealizedPnl: 5_000,
137
+ }],
138
+ });
139
+ const financials = createFinancials({ symbol: "SIVE", price: 100, currency: "SEK" });
140
+
141
+ await renderPane({
142
+ config: createSizerConfig("SIVE"),
143
+ ticker,
144
+ financials,
145
+ exchangeRates: new Map([["USD", 1], ["SEK", 0.1]]),
146
+ });
147
+ await flushFrame();
148
+
149
+ const frame = testSetup!.captureCharFrame();
150
+ expect(frame).toContain("SIVE · Main Portfolio");
151
+ expect(frame).toMatch(/Current\s+1000\s+USD/);
152
+ expect(frame).not.toMatch(/Current\s+10000\s+USD/);
153
+ });
154
+
155
+ test("toggles sensitivity with s", async () => {
156
+ await renderPane();
157
+ await flushFrame();
158
+
159
+ await act(async () => {
160
+ (testSetup!.renderer.keyInput as any).emit("keypress", {
161
+ name: "s",
162
+ sequence: "s",
163
+ ctrl: false,
164
+ meta: false,
165
+ shift: false,
166
+ });
167
+ await testSetup!.renderOnce();
168
+ });
169
+ await flushFrame();
170
+
171
+ const frame = testSetup!.captureCharFrame();
172
+ expect(frame).toContain("Sensitivity");
173
+ expect(frame).toContain("Win p");
174
+ expect(frame).toContain("Upside");
175
+ expect(frame).not.toContain("Kelly Curve");
176
+ });
177
+
178
+ test("focuses ticker search with t", async () => {
179
+ await renderPane();
180
+ await flushFrame();
181
+
182
+ await act(async () => {
183
+ (testSetup!.renderer.keyInput as any).emit("keypress", {
184
+ name: "t",
185
+ sequence: "t",
186
+ ctrl: false,
187
+ meta: false,
188
+ shift: false,
189
+ });
190
+ await testSetup!.renderOnce();
191
+ });
192
+ await flushFrame();
193
+
194
+ const frame = testSetup!.captureCharFrame();
195
+ expect(frame).toContain("/ AAPL");
196
+ });
197
+ });
@@ -0,0 +1,47 @@
1
+ import type { GloomPlugin, PaneTemplateContext, PaneTemplateCreateOptions } from "../../../types/plugin";
2
+ import { DEFAULT_FLOATING_SIZE, KELLY_PANE_ID } from "./constants";
3
+ import { KellySizerPane } from "./pane";
4
+
5
+ function resolveTemplateSymbol(context: PaneTemplateContext, options?: PaneTemplateCreateOptions): string | null {
6
+ return options?.symbol
7
+ ?? options?.ticker?.metadata.ticker
8
+ ?? options?.arg?.trim().toUpperCase()
9
+ ?? context.activeTicker
10
+ ?? null;
11
+ }
12
+
13
+ export const kellySizerPlugin: GloomPlugin = {
14
+ id: KELLY_PANE_ID,
15
+ name: "Position Sizer",
16
+ version: "1.0.0",
17
+ description: "Size positions from Kelly, risk budget, and asymmetric payoff assumptions.",
18
+ toggleable: true,
19
+ panes: [
20
+ {
21
+ id: KELLY_PANE_ID,
22
+ name: "Position Sizer",
23
+ icon: "K",
24
+ component: KellySizerPane,
25
+ defaultPosition: "right",
26
+ defaultMode: "floating",
27
+ defaultFloatingSize: DEFAULT_FLOATING_SIZE,
28
+ },
29
+ ],
30
+ paneTemplates: [
31
+ {
32
+ id: "kelly-sizer-pane",
33
+ paneId: KELLY_PANE_ID,
34
+ label: "Position Sizer",
35
+ description: "Open Kelly-based position sizing for a ticker.",
36
+ keywords: ["kelly", "position", "sizing", "risk", "bet", "portfolio"],
37
+ shortcut: { prefix: "KELLY", argPlaceholder: "ticker", argKind: "ticker", argOptional: true },
38
+ canCreate: (context, options) => !!resolveTemplateSymbol(context, options),
39
+ createInstance: (context, options) => {
40
+ const symbol = resolveTemplateSymbol(context, options);
41
+ return symbol ? { params: { symbol }, placement: "floating" } : null;
42
+ },
43
+ },
44
+ ],
45
+ };
46
+
47
+ export default kellySizerPlugin;