gloomberb 0.9.3 → 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 (137) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app/runtime/desktop-menu.ts +5 -1
  4. package/src/app/runtime/ticker-refresh.ts +4 -1
  5. package/src/app.tsx +10 -1
  6. package/src/brokers/account-cache.ts +1 -1
  7. package/src/brokers/sync-broker-instance.test.ts +107 -0
  8. package/src/brokers/sync-broker-instance.ts +22 -10
  9. package/src/cli/desktop-pane-shot.ts +2 -1
  10. package/src/cli/pane-functions/screenshot.ts +13 -1
  11. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  12. package/src/components/chart/bar-chart-renderer.ts +226 -27
  13. package/src/components/chart/core/pointer.ts +32 -6
  14. package/src/components/chart/price-axis-labels.tsx +3 -1
  15. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  16. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  17. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  18. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  19. package/src/components/chart/static/chart/surface.tsx +167 -24
  20. package/src/components/chart/stock-chart/index.test.ts +25 -0
  21. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  22. package/src/components/layout/detached-pane-shell.tsx +6 -3
  23. package/src/components/layout/floating-pane.tsx +3 -2
  24. package/src/components/layout/pane/content.tsx +20 -8
  25. package/src/components/layout/pane/footer/registration.tsx +6 -2
  26. package/src/components/layout/pane/frame.tsx +4 -4
  27. package/src/components/layout/pane/header.tsx +40 -4
  28. package/src/components/layout/pane/index.tsx +3 -1
  29. package/src/components/layout/pane/sizing.test.ts +43 -0
  30. package/src/components/layout/pane/sizing.ts +13 -4
  31. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  32. package/src/components/layout/shell/drag/runtime.ts +9 -2
  33. package/src/components/layout/shell/index.test.tsx +92 -2
  34. package/src/components/layout/shell/index.tsx +37 -2
  35. package/src/components/layout/shell/layout-state.test.ts +33 -0
  36. package/src/components/layout/shell/layout-state.ts +4 -9
  37. package/src/components/layout/shell/pane/layers.tsx +8 -2
  38. package/src/components/layout/shell/visible-layout.ts +17 -0
  39. package/src/components/layout/status-bar.test.tsx +6 -1
  40. package/src/components/layout/status-bar.tsx +5 -1
  41. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  42. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  43. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  44. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  45. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  46. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  47. package/src/components/table-view-shared.tsx +12 -4
  48. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  49. package/src/components/ui/data-table/types.ts +4 -0
  50. package/src/components/ui/fields.tsx +10 -3
  51. package/src/components/ui/page-stack-view.tsx +6 -2
  52. package/src/components/ui/table-layout.test.ts +25 -0
  53. package/src/components/ui/table-layout.ts +22 -6
  54. package/src/components/ui/tabs.tsx +26 -10
  55. package/src/components/ui/ui.test.tsx +87 -0
  56. package/src/core/state/app/layout-reducer.ts +2 -2
  57. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  58. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  59. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  60. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  61. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  62. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  63. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  64. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  65. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  66. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  67. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  68. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  69. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  70. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  71. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  72. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  73. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  74. package/src/plugins/builtin/options/view.test.tsx +141 -4
  75. package/src/plugins/builtin/options/view.tsx +76 -15
  76. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  77. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  78. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  79. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  80. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  81. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  83. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  84. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  85. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  86. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  87. package/src/plugins/catalog-ui.ts +2 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  89. package/src/plugins/ibkr/config.test.ts +31 -2
  90. package/src/plugins/ibkr/config.ts +9 -2
  91. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  92. package/src/plugins/ibkr/flex/index.ts +30 -0
  93. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  94. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  95. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  96. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  97. package/src/plugins/pane-manager/gridlock.ts +13 -5
  98. package/src/plugins/pane-manager/layout-state.ts +25 -0
  99. package/src/plugins/pane-manager.test.ts +33 -0
  100. package/src/plugins/pane-manager.ts +6 -1
  101. package/src/plugins/prediction-markets/chart.tsx +10 -4
  102. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  103. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  104. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  105. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  106. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  107. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  108. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  109. package/src/plugins/prediction-markets/pane.tsx +50 -22
  110. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  111. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  112. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  113. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  114. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  115. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  116. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  117. package/src/renderers/electrobun/view/styles.css +11 -0
  118. package/src/renderers/opentui/host.tsx +17 -1
  119. package/src/renderers/opentui/ui-host.tsx +57 -25
  120. package/src/sources/provider-router/batches.ts +16 -2
  121. package/src/sources/provider-router/financial-routes.ts +5 -0
  122. package/src/sources/provider-router/financials.ts +11 -23
  123. package/src/sources/provider-router/index.test.ts +189 -0
  124. package/src/sources/provider-router/primary.ts +3 -2
  125. package/src/sources/sec-edgar.test.ts +83 -0
  126. package/src/sources/sec-edgar.ts +231 -0
  127. package/src/sources/yahoo-finance.test.ts +51 -0
  128. package/src/sources/yahoo-finance.ts +49 -1
  129. package/src/state/app/context/index.test.tsx +5 -8
  130. package/src/types/broker.ts +1 -0
  131. package/src/types/config.ts +10 -0
  132. package/src/types/trading.ts +2 -0
  133. package/src/ui/host.tsx +16 -0
  134. package/src/ui/index.tsx +2 -0
  135. package/src/utils/financial-statements.ts +23 -0
  136. package/src/version.ts +1 -1
  137. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -31,6 +31,7 @@ export interface TabsProps {
31
31
  focused?: boolean;
32
32
  keyboardNavigation?: boolean;
33
33
  scrollable?: boolean;
34
+ scrollId?: string;
34
35
  }
35
36
 
36
37
  const WHEEL_DELTA_PER_CELL = 8;
@@ -47,6 +48,7 @@ export function Tabs({
47
48
  focused = false,
48
49
  keyboardNavigation = true,
49
50
  scrollable = true,
51
+ scrollId,
50
52
  }: TabsProps) {
51
53
  const ui = useUiHost();
52
54
  const NativeTabs = ui.Tabs;
@@ -57,11 +59,11 @@ export function Tabs({
57
59
  navigationValueRef.current = activeValue;
58
60
  }
59
61
  const palette = {
60
- activeFg: variant === "bare" ? colors.textBright : colors.text,
62
+ activeFg: focused || variant === "bare" ? colors.textBright : colors.text,
61
63
  inactiveFg: colors.textDim,
62
64
  disabledFg: colors.textMuted,
63
65
  hoverFg: colors.text,
64
- activeUnderline: colors.borderFocused,
66
+ activeUnderline: focused ? colors.textBright : colors.borderFocused,
65
67
  inactiveUnderline: colors.bg,
66
68
  hoverUnderline: colors.border,
67
69
  hoverBg: hoverBg(),
@@ -118,6 +120,7 @@ export function Tabs({
118
120
  closeMode={closeMode}
119
121
  addLabel={addLabel}
120
122
  onAdd={onAdd}
123
+ focused={focused}
121
124
  palette={palette}
122
125
  />
123
126
  );
@@ -133,7 +136,9 @@ export function Tabs({
133
136
  closeMode={closeMode}
134
137
  addLabel={addLabel}
135
138
  onAdd={onAdd}
139
+ focused={focused}
136
140
  scrollable={scrollable}
141
+ scrollId={scrollId}
137
142
  palette={palette}
138
143
  />
139
144
  );
@@ -157,7 +162,9 @@ function OpenTuiTabs({
157
162
  closeMode = "always",
158
163
  addLabel = "+",
159
164
  onAdd,
165
+ focused = false,
160
166
  scrollable = true,
167
+ scrollId,
161
168
  palette,
162
169
  }: TabsProps & {
163
170
  palette: {
@@ -187,7 +194,7 @@ function OpenTuiTabs({
187
194
  useEffect(() => {
188
195
  const scrollBox = scrollRef.current;
189
196
  const activeIndex = tabs.findIndex((tab) => tab.value === activeValue);
190
- const viewportWidth = scrollBox?.viewport?.width ?? 0;
197
+ const viewportWidth = scrollBox?.viewport?.width || scrollBox?.width || 0;
191
198
  if (!scrollBox || activeIndex < 0 || viewportWidth <= 0) return;
192
199
 
193
200
  const activeLeft = tabWidths.slice(0, activeIndex).reduce((sum, width) => sum + width, 0);
@@ -195,14 +202,15 @@ function OpenTuiTabs({
195
202
  const currentLeft = scrollBox.scrollLeft ?? 0;
196
203
  const currentRight = currentLeft + viewportWidth;
197
204
  const maxScrollLeft = Math.max(0, totalWidth - viewportWidth);
205
+ const scrollToLeft = (left: number) => {
206
+ scrollBox.scrollLeft = left;
207
+ scrollBox.scrollTo({ x: left, y: scrollBox.scrollTop });
208
+ };
198
209
 
199
210
  if (activeLeft < currentLeft) {
200
- scrollBox.scrollTo({ x: activeLeft, y: scrollBox.scrollTop });
211
+ scrollToLeft(activeLeft);
201
212
  } else if (activeRight > currentRight) {
202
- scrollBox.scrollTo({
203
- x: Math.min(activeRight - viewportWidth, maxScrollLeft),
204
- y: scrollBox.scrollTop,
205
- });
213
+ scrollToLeft(Math.min(activeRight - viewportWidth, maxScrollLeft));
206
214
  }
207
215
  }, [activeValue, tabWidths, tabs, totalWidth]);
208
216
 
@@ -214,7 +222,7 @@ function OpenTuiTabs({
214
222
  const direction = event?.scroll?.direction;
215
223
  if (!direction) return;
216
224
  const scrollBox = scrollRef.current;
217
- const viewportWidth = scrollBox?.viewport?.width ?? 0;
225
+ const viewportWidth = scrollBox?.viewport?.width || scrollBox?.width || 0;
218
226
  if (!scrollBox || viewportWidth <= 0 || totalWidth <= viewportWidth) return;
219
227
 
220
228
  event.preventDefault?.();
@@ -228,6 +236,7 @@ function OpenTuiTabs({
228
236
  0,
229
237
  Math.min(maxScrollLeft, (scrollBox.scrollLeft ?? 0) + directionSign * deltaCells),
230
238
  );
239
+ scrollBox.scrollLeft = nextLeft;
231
240
  scrollBox.scrollTo({ x: nextLeft, y: scrollBox.scrollTop });
232
241
  };
233
242
 
@@ -236,10 +245,16 @@ function OpenTuiTabs({
236
245
  {tabs.map((tab, index) => {
237
246
  const active = tab.value === activeValue;
238
247
  const hovered = hoveredValue === tab.value && !tab.disabled;
248
+ const focusedActive = focused && active;
239
249
  const tabWidth = tabWidths[index] ?? tab.label.length + 2;
240
250
  const showClose = !!tab.onClose && (closeMode === "always" || active);
241
251
  const attributes = (active ? TextAttributes.BOLD : 0)
242
- | (variant === "underline" && !compact && (active || hovered) ? TextAttributes.UNDERLINE : 0);
252
+ | (
253
+ (variant === "underline" && !compact && (active || hovered))
254
+ || (variant === "bare" && focusedActive)
255
+ ? TextAttributes.UNDERLINE
256
+ : 0
257
+ );
243
258
  const startHover = tab.disabled
244
259
  ? undefined
245
260
  : () => {
@@ -323,6 +338,7 @@ function OpenTuiTabs({
323
338
 
324
339
  return (
325
340
  <ScrollBox
341
+ id={scrollId}
326
342
  ref={scrollRef}
327
343
  width="100%"
328
344
  height={1}
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useEffect, useRef, useState } from "react";
3
3
  import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
4
+ import { TextAttributes } from "../../ui";
4
5
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
5
6
  import { ChoiceDialog } from "./choice-dialog";
6
7
  import { DataTable, type DataTableColumn } from "./data-table";
@@ -128,12 +129,18 @@ function DataTableHorizontalScrollHarness({
128
129
  containerWidth = 32,
129
130
  containerHeight = 5,
130
131
  rowCount = 1,
132
+ columnGap,
133
+ horizontalPadding,
134
+ fillAvailableWidth,
131
135
  showHorizontalScrollbar,
132
136
  }: {
133
137
  columns?: DataTableColumn[];
134
138
  containerWidth?: number;
135
139
  containerHeight?: number;
136
140
  rowCount?: number;
141
+ columnGap?: number;
142
+ horizontalPadding?: number;
143
+ fillAvailableWidth?: boolean;
137
144
  showHorizontalScrollbar?: boolean;
138
145
  }) {
139
146
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
@@ -162,11 +169,45 @@ function DataTableHorizontalScrollHarness({
162
169
  getItemKey={(row) => row.id}
163
170
  renderCell={(row) => ({ text: row.name })}
164
171
  emptyStateTitle="No rows."
172
+ columnGap={columnGap}
173
+ horizontalPadding={horizontalPadding}
174
+ fillAvailableWidth={fillAvailableWidth}
165
175
  showHorizontalScrollbar={showHorizontalScrollbar}
166
176
  />
167
177
  );
168
178
  }
169
179
 
180
+ function DataTableColumnGapHarness() {
181
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
182
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
183
+ const items = [{ id: "row-0", left: "AA", right: "BB" }];
184
+
185
+ return (
186
+ <DataTableView
187
+ rootWidth={8}
188
+ rootHeight={4}
189
+ selection={{ kind: "none" }}
190
+ columns={[
191
+ { id: "left", label: "L", width: 2, align: "left" },
192
+ { id: "right", label: "R", width: 2, align: "left" },
193
+ ]}
194
+ items={items}
195
+ sortColumnId={null}
196
+ sortDirection="asc"
197
+ onHeaderClick={() => {}}
198
+ headerScrollRef={headerScrollRef}
199
+ scrollRef={scrollRef}
200
+ getItemKey={(row) => row.id}
201
+ renderCell={(row, column) => ({ text: column.id === "left" ? row.left : row.right })}
202
+ emptyStateTitle="No rows."
203
+ columnGap={0}
204
+ horizontalPadding={0}
205
+ fillAvailableWidth={false}
206
+ showHorizontalScrollbar={false}
207
+ />
208
+ );
209
+ }
210
+
170
211
  function DataTableVirtualizationHarness() {
171
212
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
172
213
  const scrollRef = useRef<ScrollBoxRenderable>(null);
@@ -360,6 +401,31 @@ describe("shared UI kit", () => {
360
401
  expect(lastSelected).toBe("chart");
361
402
  });
362
403
 
404
+ test("marks the active bare tab when it has keyboard focus", async () => {
405
+ testSetup = await testRender(
406
+ <Tabs
407
+ tabs={[
408
+ { label: "One", value: "one" },
409
+ { label: "Two", value: "two" },
410
+ ]}
411
+ activeValue="one"
412
+ onSelect={() => {}}
413
+ variant="bare"
414
+ compact
415
+ focused
416
+ />,
417
+ { width: 24, height: 2 },
418
+ );
419
+
420
+ await act(async () => {
421
+ await testSetup!.renderOnce();
422
+ });
423
+
424
+ const activeSpan = testSetup.captureSpans().lines[0]?.spans.find((span) => span.text.includes("One"));
425
+ expect(activeSpan).toBeDefined();
426
+ expect((activeSpan!.attributes & TextAttributes.UNDERLINE)).toBe(TextAttributes.UNDERLINE);
427
+ });
428
+
363
429
  test("selects tabs by clicking their text labels", async () => {
364
430
  let lastSelected = "overview";
365
431
  const selectedValues: string[] = [];
@@ -737,6 +803,27 @@ describe("shared UI kit", () => {
737
803
  expect(tableScrollBoxForTest?.horizontalScrollBar.visible).toBe(false);
738
804
  });
739
805
 
806
+ test("renders data table columns without spacer cells when column gap is zero", async () => {
807
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
808
+ testSetup = await testRender(
809
+ <AppContext value={{ state, dispatch: () => {} }}>
810
+ <PaneInstanceProvider paneId="portfolio-list:main">
811
+ <DataTableColumnGapHarness />
812
+ </PaneInstanceProvider>
813
+ </AppContext>,
814
+ { width: 8, height: 4 },
815
+ );
816
+
817
+ await act(async () => {
818
+ await testSetup!.renderOnce();
819
+ await testSetup!.renderOnce();
820
+ });
821
+
822
+ const frame = testSetup.captureCharFrame();
823
+ expect(frame).toContain("AABB");
824
+ expect(frame).not.toContain("AA BB");
825
+ });
826
+
740
827
  test("hides data table horizontal scrolling when content fits", async () => {
741
828
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
742
829
  tableScrollBoxForTest = null;
@@ -1,4 +1,4 @@
1
- import { cloneLayout, DEFAULT_LAYOUT, type SavedLayout } from "../../../types/config";
1
+ import { cloneLayout, createBlankLayout, type SavedLayout } from "../../../types/config";
2
2
  import {
3
3
  clonePaneStateMap,
4
4
  cloneSavedLayout,
@@ -96,7 +96,7 @@ export function reduceLayoutAction(state: AppState, action: AppAction): AppState
96
96
  );
97
97
  const newLayout: SavedLayout = {
98
98
  name: action.name,
99
- layout: cloneLayout(DEFAULT_LAYOUT),
99
+ layout: createBlankLayout(),
100
100
  paneState: {},
101
101
  };
102
102
  const layouts = [...currentConfig.layouts, newLayout];
@@ -293,12 +293,14 @@ describe("PortfolioAnalyticsPane", () => {
293
293
  currency: "USD",
294
294
  source: "flex",
295
295
  updatedAt: new Date(2026, 2, 27).getTime(),
296
+ asOfDate: "2026-03-26",
296
297
  totalCashValue: -50000,
297
298
  settledCash: -45000,
298
299
  availableFunds: 15000,
299
300
  excessLiquidity: 12000,
300
301
  buyingPower: 30000,
301
302
  netLiquidation: 125000,
303
+ grossPositionValue: 113636,
302
304
  dailyPnl: 900,
303
305
  unrealizedPnl: 777,
304
306
  realizedPnl: -25,
@@ -318,6 +320,8 @@ describe("PortfolioAnalyticsPane", () => {
318
320
 
319
321
  const frame = testSetup!.captureCharFrame();
320
322
  expect(frame).toContain("Net Liq 125k");
323
+ expect(frame).toContain("Val 113.6k");
324
+ expect(frame).toContain("Margin Lev 1.1x");
321
325
  expect(frame).toContain("Cash -50k");
322
326
  expect(frame).toContain("Day +900");
323
327
  expect(frame).toContain("P&L +777");
@@ -326,8 +330,8 @@ describe("PortfolioAnalyticsPane", () => {
326
330
  expect(frame).toContain("Avail 15k");
327
331
  expect(frame).toContain("Excess 12k");
328
332
  expect(frame).toContain("BP 30k");
329
- expect(frame).toContain("Synced just now");
330
- expect(frame).toContain("Source Flex Mar 27");
333
+ expect(frame).toContain("As Of Mar 26");
334
+ expect(frame).toContain("Source Flex Mar 26");
331
335
  });
332
336
 
333
337
  test("falls back from a Gateway portfolio to a configured Flex profile for IBKR history", async () => {
@@ -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
  }
@@ -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
+ }