gloomberb 0.9.3 → 0.9.5

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 (176) hide show
  1. package/README.md +1 -0
  2. package/package.json +3 -2
  3. package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
  4. package/patches/react-reconciler@0.33.0.patch +10 -0
  5. package/src/app/runtime/desktop-menu.ts +5 -1
  6. package/src/app/runtime/ticker-refresh.ts +4 -1
  7. package/src/app.tsx +10 -1
  8. package/src/brokers/account-cache.ts +1 -1
  9. package/src/brokers/sync-broker-instance.test.ts +107 -0
  10. package/src/brokers/sync-broker-instance.ts +22 -10
  11. package/src/cli/desktop-pane-shot.ts +2 -1
  12. package/src/cli/entry.ts +8 -1
  13. package/src/cli/native-smoke.ts +5 -0
  14. package/src/cli/pane-functions/screenshot.ts +13 -1
  15. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  16. package/src/components/chart/bar-chart-renderer.ts +226 -27
  17. package/src/components/chart/core/pointer.ts +32 -6
  18. package/src/components/chart/native/bitmap-support.test.ts +27 -0
  19. package/src/components/chart/native/bitmap-support.ts +55 -0
  20. package/src/components/chart/native/kitty/index.test.ts +47 -0
  21. package/src/components/chart/native/surface/visibility.ts +8 -1
  22. package/src/components/chart/price-axis-labels.tsx +3 -1
  23. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  24. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  25. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  26. package/src/components/chart/static/chart/bitmap.ts +16 -9
  27. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  28. package/src/components/chart/static/chart/surface.tsx +167 -24
  29. package/src/components/chart/stock-chart/index.test.ts +25 -0
  30. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  31. package/src/components/command-bar/list/view.tsx +1 -1
  32. package/src/components/command-bar/theme-picker.tsx +1 -1
  33. package/src/components/data-table/view.tsx +0 -12
  34. package/src/components/layout/detached-pane-shell.tsx +6 -3
  35. package/src/components/layout/floating-pane.tsx +6 -5
  36. package/src/components/layout/pane/content.tsx +20 -8
  37. package/src/components/layout/pane/footer/registration.tsx +6 -2
  38. package/src/components/layout/pane/frame.tsx +4 -4
  39. package/src/components/layout/pane/header.tsx +45 -4
  40. package/src/components/layout/pane/index.tsx +6 -4
  41. package/src/components/layout/pane/sizing.test.ts +43 -0
  42. package/src/components/layout/pane/sizing.ts +13 -4
  43. package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
  44. package/src/components/layout/shell/cursor-occlusion.ts +204 -0
  45. package/src/components/layout/shell/drag/runtime.ts +9 -2
  46. package/src/components/layout/shell/index.test.tsx +92 -2
  47. package/src/components/layout/shell/index.tsx +45 -3
  48. package/src/components/layout/shell/layout-state.test.ts +33 -0
  49. package/src/components/layout/shell/layout-state.ts +4 -9
  50. package/src/components/layout/shell/pane/layers.tsx +10 -4
  51. package/src/components/layout/shell/visible-layout.ts +17 -0
  52. package/src/components/layout/status-bar.test.tsx +6 -1
  53. package/src/components/layout/status-bar.tsx +10 -6
  54. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  55. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  56. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  57. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  58. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  59. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  60. package/src/components/pane-settings-dialog/desktop.tsx +1 -1
  61. package/src/components/price-performance.tsx +1 -1
  62. package/src/components/price-sparkline/view.tsx +15 -14
  63. package/src/components/speedometer-gauge/terminal.tsx +18 -15
  64. package/src/components/table-view-shared.tsx +12 -14
  65. package/src/components/ticker/list-table-view.tsx +1 -7
  66. package/src/components/ticker/list-table.test.tsx +0 -4
  67. package/src/components/ui/data-table/opentui/index.tsx +29 -22
  68. package/src/components/ui/data-table/types.ts +10 -4
  69. package/src/components/ui/fields.tsx +10 -3
  70. package/src/components/ui/list-view.tsx +1 -1
  71. package/src/components/ui/page-stack-view.tsx +6 -2
  72. package/src/components/ui/table-layout.test.ts +25 -0
  73. package/src/components/ui/table-layout.ts +22 -6
  74. package/src/components/ui/tabs.tsx +27 -12
  75. package/src/components/ui/ui.test.tsx +87 -9
  76. package/src/core/state/app/layout-reducer.ts +2 -2
  77. package/src/plugins/builtin/account-management/form-components.tsx +2 -2
  78. package/src/plugins/builtin/alerts/pane.tsx +1 -1
  79. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  80. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  81. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  82. package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
  83. package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
  84. package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
  85. package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
  86. package/src/plugins/builtin/chat/sidebar.tsx +1 -1
  87. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  88. package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
  89. package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
  90. package/src/plugins/builtin/debug/index.tsx +3 -4
  91. package/src/plugins/builtin/help/components.tsx +1 -1
  92. package/src/plugins/builtin/holders/treemap.tsx +1 -1
  93. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  94. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  95. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  96. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  97. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  98. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  99. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  100. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  101. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  102. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  103. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  104. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  105. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  106. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  107. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  108. package/src/plugins/builtin/options/table.ts +5 -5
  109. package/src/plugins/builtin/options/view.test.tsx +141 -4
  110. package/src/plugins/builtin/options/view.tsx +76 -15
  111. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  112. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  113. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  114. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  115. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  116. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  117. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  118. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  119. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  120. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  121. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  122. package/src/plugins/catalog-ui.ts +2 -0
  123. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  124. package/src/plugins/ibkr/config.test.ts +31 -2
  125. package/src/plugins/ibkr/config.ts +9 -2
  126. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  127. package/src/plugins/ibkr/flex/index.ts +30 -0
  128. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  129. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  130. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  131. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  132. package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
  133. package/src/plugins/pane-manager/gridlock.ts +13 -5
  134. package/src/plugins/pane-manager/layout-state.ts +25 -0
  135. package/src/plugins/pane-manager.test.ts +33 -0
  136. package/src/plugins/pane-manager.ts +6 -1
  137. package/src/plugins/prediction-markets/chart.tsx +10 -4
  138. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  139. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  140. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  141. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  142. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  143. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  144. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  145. package/src/plugins/prediction-markets/pane.tsx +50 -22
  146. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  147. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  148. package/src/renderers/electrobun/view/data-table/index.tsx +19 -12
  149. package/src/renderers/electrobun/view/data-table/row.tsx +32 -18
  150. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  151. package/src/renderers/electrobun/view/host/box.tsx +6 -1
  152. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  153. package/src/renderers/electrobun/view/host/style.ts +1 -0
  154. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  155. package/src/renderers/electrobun/view/styles.css +18 -0
  156. package/src/renderers/opentui/host.tsx +17 -1
  157. package/src/renderers/opentui/native-smoke.ts +1 -0
  158. package/src/renderers/opentui/ui-host.tsx +57 -25
  159. package/src/sources/provider-router/batches.ts +16 -2
  160. package/src/sources/provider-router/financial-routes.ts +5 -0
  161. package/src/sources/provider-router/financials.ts +11 -23
  162. package/src/sources/provider-router/index.test.ts +189 -0
  163. package/src/sources/provider-router/primary.ts +3 -2
  164. package/src/sources/sec-edgar.test.ts +83 -0
  165. package/src/sources/sec-edgar.ts +231 -0
  166. package/src/sources/yahoo-finance.test.ts +51 -0
  167. package/src/sources/yahoo-finance.ts +49 -1
  168. package/src/state/app/context/index.test.tsx +5 -8
  169. package/src/types/broker.ts +1 -0
  170. package/src/types/config.ts +10 -0
  171. package/src/types/trading.ts +2 -0
  172. package/src/ui/host.tsx +16 -0
  173. package/src/ui/index.tsx +2 -0
  174. package/src/utils/financial-statements.ts +23 -0
  175. package/src/version.ts +1 -1
  176. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -7,7 +7,7 @@ import { formatCompact, formatNumber, formatPercentRaw } from "../../../utils/fo
7
7
  import { formatRelativeAge } from "../../../utils/relative-time";
8
8
  import { instrumentFromTicker, type ChartRequest } from "../../../market-data/request-types";
9
9
  import { buildChartKey } from "../../../market-data/selectors";
10
- import { resolvePortfolioAccountMetrics } from "../portfolio-list/account-metrics";
10
+ import { resolvePortfolioAccountMetrics, resolvePortfolioMarketValue } from "../portfolio-list/account-metrics";
11
11
  import type { ColumnContext, PortfolioSummaryTotals } from "../portfolio-list/metrics";
12
12
  import type { ResolvedPortfolioAccountState } from "../portfolio-list/summary";
13
13
  import { performancePointValue } from "./broker-performance";
@@ -38,6 +38,31 @@ export type ChartEntryLookup = Map<string, {
38
38
  lastGoodData?: PricePoint[] | null;
39
39
  } | undefined>;
40
40
 
41
+ function formatIsoDateMonthDay(value: string): string {
42
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
43
+ if (!match) return value;
44
+ const [, year, month, day] = match;
45
+ return new Date(Number(year), Number(month) - 1, Number(day)).toLocaleDateString("en-US", {
46
+ month: "short",
47
+ day: "numeric",
48
+ });
49
+ }
50
+
51
+ function formatAccountFreshness(account: ResolvedPortfolioAccountState["account"] | undefined): string | null {
52
+ if (!account) return null;
53
+ if (account.asOfDate) return formatIsoDateMonthDay(account.asOfDate);
54
+ return account.updatedAt ? formatRelativeAge(account.updatedAt) : null;
55
+ }
56
+
57
+ function finiteNumber(value: unknown): value is number {
58
+ return typeof value === "number" && Number.isFinite(value);
59
+ }
60
+
61
+ function formatMarginLeverage(netLiquidation: number | undefined, totalMarketValue: number): string | null {
62
+ if (!finiteNumber(netLiquidation) || !finiteNumber(totalMarketValue) || totalMarketValue <= 0) return null;
63
+ return `${(netLiquidation / totalMarketValue).toFixed(1)}x`;
64
+ }
65
+
41
66
  export function buildPortfolioChartTargets(portfolioTickers: TickerRecord[]): PortfolioChartTarget[] {
42
67
  return portfolioTickers.flatMap((ticker) => {
43
68
  const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
@@ -96,7 +121,6 @@ export function buildBenchmarkReturnSeries(
96
121
 
97
122
  export function buildAnalyticsSummaryRows({
98
123
  accountState,
99
- activePortfolio,
100
124
  brokerPerformance,
101
125
  portfolioStats,
102
126
  }: {
@@ -108,7 +132,8 @@ export function buildAnalyticsSummaryRows({
108
132
  const rows: AnalyticsMetricRow[] = [];
109
133
  const account = accountState?.account;
110
134
  const accountMetrics = resolvePortfolioAccountMetrics(portfolioStats, account);
111
- const syncedAt = activePortfolio?.lastSyncedAt ?? account?.updatedAt;
135
+ const accountFreshness = formatAccountFreshness(account);
136
+ const totalMarketValue = resolvePortfolioMarketValue(portfolioStats, account);
112
137
 
113
138
  if (account?.netLiquidation != null) {
114
139
  rows.push({
@@ -122,10 +147,20 @@ export function buildAnalyticsSummaryRows({
122
147
  rows.push({
123
148
  id: "total-value",
124
149
  label: "Val",
125
- value: formatCompact(portfolioStats.totalMktValue),
150
+ value: formatCompact(totalMarketValue),
126
151
  color: colors.text,
127
152
  });
128
153
 
154
+ const marginLeverage = formatMarginLeverage(account?.netLiquidation, totalMarketValue);
155
+ if (marginLeverage) {
156
+ rows.push({
157
+ id: "margin-leverage",
158
+ label: "Margin Lev",
159
+ value: marginLeverage,
160
+ color: colors.text,
161
+ });
162
+ }
163
+
129
164
  if (account?.totalCashValue != null) {
130
165
  rows.push({
131
166
  id: "cash",
@@ -202,11 +237,11 @@ export function buildAnalyticsSummaryRows({
202
237
  });
203
238
  }
204
239
  if (accountState) {
205
- if (syncedAt) {
240
+ if (accountFreshness) {
206
241
  rows.push({
207
- id: "last-sync",
208
- label: "Synced",
209
- value: formatRelativeAge(syncedAt),
242
+ id: "account-freshness",
243
+ label: "As Of",
244
+ value: accountFreshness,
210
245
  color: colors.textDim,
211
246
  });
212
247
  }
@@ -26,7 +26,7 @@ interface BuildoutCellContext {
26
26
  export function renderBuildoutCell(
27
27
  row: BuildoutRow,
28
28
  column: BuildoutColumn,
29
- rowState: { selected: boolean; hovered: boolean },
29
+ rowState: { selected: boolean },
30
30
  context: BuildoutCellContext,
31
31
  ): DataTableCell {
32
32
  const selectedColor = rowState.selected ? colors.selectedText : undefined;
@@ -314,7 +314,7 @@ export function BuildoutPane({ focused, width, height }: PaneProps) {
314
314
  row: BuildoutRow,
315
315
  column: BuildoutColumn,
316
316
  _index: number,
317
- rowState: { selected: boolean; hovered: boolean },
317
+ rowState: { selected: boolean },
318
318
  ) => renderBuildoutCell(row, column, rowState, {
319
319
  favoriteBusyKey,
320
320
  toggleFavorite,
@@ -97,7 +97,7 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
97
97
  <Text
98
98
  fg={state.authorColor}
99
99
  attributes={state.authorAttributes}
100
- onMouseMove={() => onUserHover(msg.user)}
100
+ onMouseOver={() => onUserHover(msg.user)}
101
101
  onMouseOut={onUserHoverEnd}
102
102
  style={{ cursor: "default" }}
103
103
  >
@@ -58,7 +58,7 @@ export function ResponsiveTickerBadgeText({
58
58
  height={1}
59
59
  flexDirection="row"
60
60
  backgroundColor={blendHex(colors.panel, colors.positive, 0.24)}
61
- onMouseMove={() => {
61
+ onMouseOver={() => {
62
62
  if (user) onUserHover?.(user);
63
63
  }}
64
64
  onMouseOut={() => {
@@ -44,7 +44,7 @@ export function TerminalChatMessage({
44
44
  const messageRowProps = {
45
45
  width: contentWidth,
46
46
  backgroundColor: state.bgColor,
47
- onMouseMove: setHovered,
47
+ onMouseOver: setHovered,
48
48
  onMouseOut: clearHovered,
49
49
  };
50
50
 
@@ -80,7 +80,7 @@ export function TerminalChatMessage({
80
80
  <Text
81
81
  fg={state.authorColor}
82
82
  attributes={state.authorAttributes}
83
- onMouseMove={() => onUserHover(msg.user)}
83
+ onMouseOver={() => onUserHover(msg.user)}
84
84
  onMouseOut={onUserHoverEnd}
85
85
  >
86
86
  {msg.user.username ?? "anon"}
@@ -239,7 +239,7 @@ export function ChannelSidebar({
239
239
  flexDirection="row"
240
240
  backgroundColor={bg}
241
241
  onMouseDown={selectChannel}
242
- onMouseMove={() => setHoveredChannelId((current) => (current === channel.id ? current : channel.id))}
242
+ onMouseOver={() => setHoveredChannelId((current) => (current === channel.id ? current : channel.id))}
243
243
  onMouseOut={() => setHoveredChannelId((current) => (current === channel.id ? null : current))}
244
244
  style={{ cursor: "pointer" }}
245
245
  >
@@ -98,7 +98,7 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
98
98
  <Box
99
99
  flexDirection="row"
100
100
  backgroundColor={hovered ? hoverBg() : undefined}
101
- onMouseMove={() => setHovered((current) => (current ? current : true))}
101
+ onMouseOver={() => setHovered((current) => (current ? current : true))}
102
102
  onMouseOut={() => setHovered((current) => (current ? false : current))}
103
103
  onMouseDown={openChat}
104
104
  >
@@ -22,7 +22,7 @@ export function InlineAuthActions({ showSignup = true }: { showSignup?: boolean
22
22
  <Box flexDirection="row">
23
23
  <Box
24
24
  backgroundColor={hoveredAction === "login" ? hoverBg() : undefined}
25
- onMouseMove={() => setHoveredAction((current) => (current === "login" ? current : "login"))}
25
+ onMouseOver={() => setHoveredAction((current) => (current === "login" ? current : "login"))}
26
26
  onMouseOut={() => setHoveredAction((current) => (current === "login" ? null : current))}
27
27
  onMouseDown={(event: any) => openAuthCommand(openCommandBar, "Log In", event)}
28
28
  >
@@ -33,7 +33,7 @@ export function InlineAuthActions({ showSignup = true }: { showSignup?: boolean
33
33
  <Text fg={colors.textDim}>/</Text>
34
34
  <Box
35
35
  backgroundColor={hoveredAction === "signup" ? hoverBg() : undefined}
36
- onMouseMove={() => setHoveredAction((current) => (current === "signup" ? current : "signup"))}
36
+ onMouseOver={() => setHoveredAction((current) => (current === "signup" ? current : "signup"))}
37
37
  onMouseOut={() => setHoveredAction((current) => (current === "signup" ? null : current))}
38
38
  onMouseDown={(event: any) => openAuthCommand(openCommandBar, "Sign Up", event)}
39
39
  >
@@ -41,7 +41,7 @@ export function SymbolLabelCell({
41
41
  overflow="hidden"
42
42
  backgroundColor={hovered ? hoverBg() : undefined}
43
43
  style={{ cursor: "pointer" }}
44
- onMouseMove={() => onHover(symbol)}
44
+ onMouseOver={() => onHover(symbol)}
45
45
  onMouseOut={() => onLeave(symbol)}
46
46
  onMouseDown={(event: any) => handleSymbolMouseDown(event, openSymbol)}
47
47
  >
@@ -64,7 +64,6 @@ function DebugPane({ focused, width, height }: PaneProps) {
64
64
  const [filterSource, setFilterSource] = useState<string | null>(null);
65
65
  const [autoScroll, setAutoScroll] = useState(true);
66
66
  const [selectedIdx, setSelectedIdx] = useState(-1);
67
- const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
68
67
  const [showDetail, setShowDetail] = useState(false);
69
68
  const sourcesRef = useRef<string[]>(debugLog.getSources());
70
69
 
@@ -261,8 +260,8 @@ function DebugPane({ focused, width, height }: PaneProps) {
261
260
  {visibleEntries.map((entry, i) => {
262
261
  const globalIdx = viewStart + i;
263
262
  const isSelected = globalIdx === selectedIdx;
264
- const isHovered = globalIdx === hoveredIdx && !isSelected;
265
- const bgColor = isSelected ? colors.selected : isHovered ? hoverBg() : undefined;
263
+ const bgColor = isSelected ? colors.selected : undefined;
264
+ const rowHoverBg = isSelected ? undefined : hoverBg();
266
265
  const ts = formatTimestamp(entry.timestamp);
267
266
  const lvl = LEVEL_LABELS[entry.level];
268
267
  const sourceTag = entry.source;
@@ -278,7 +277,7 @@ function DebugPane({ focused, width, height }: PaneProps) {
278
277
  width={contentWidth}
279
278
  flexDirection="row"
280
279
  backgroundColor={bgColor}
281
- onMouseMove={() => setHoveredIdx((current) => (current === globalIdx ? current : globalIdx))}
280
+ hoverBackgroundColor={rowHoverBg}
282
281
  onMouseDown={() => { setSelectedIdx(globalIdx); setAutoScroll(false); }}
283
282
  >
284
283
  <Text fg={colors.textMuted}> {ts} </Text>
@@ -63,7 +63,7 @@ export function ActionButton({
63
63
  return (
64
64
  <Box
65
65
  backgroundColor={hovered ? hoverBg() : colors.panel}
66
- onMouseMove={() => onHover(id)}
66
+ onMouseOver={() => onHover(id)}
67
67
  onMouseOut={() => onHover(null)}
68
68
  onMouseDown={(event: any) => {
69
69
  event.stopPropagation?.();
@@ -165,7 +165,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
165
165
  onSelect();
166
166
  }}
167
167
  onDoubleClick={onActivate}
168
- onMouseMove={() => onHover(true)}
168
+ onMouseOver={() => onHover(true)}
169
169
  onMouseOut={() => onHover(false)}
170
170
  >
171
171
  {canShowLabel && (
@@ -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
+ });