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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/app/runtime/desktop-menu.ts +5 -1
- package/src/app/runtime/ticker-refresh.ts +4 -1
- package/src/app.tsx +10 -1
- package/src/brokers/account-cache.ts +1 -1
- package/src/brokers/sync-broker-instance.test.ts +107 -0
- package/src/brokers/sync-broker-instance.ts +22 -10
- package/src/cli/desktop-pane-shot.ts +2 -1
- package/src/cli/pane-functions/screenshot.ts +13 -1
- package/src/components/chart/bar-chart-renderer.test.ts +108 -3
- package/src/components/chart/bar-chart-renderer.ts +226 -27
- package/src/components/chart/core/pointer.ts +32 -6
- package/src/components/chart/price-axis-labels.tsx +3 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
- package/src/components/chart/static/bar-chart-surface.tsx +151 -28
- package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
- package/src/components/chart/static/chart/surface.test.tsx +92 -0
- package/src/components/chart/static/chart/surface.tsx +167 -24
- package/src/components/chart/stock-chart/index.test.ts +25 -0
- package/src/components/command-bar/layout-items/current-layout.ts +5 -1
- package/src/components/layout/detached-pane-shell.tsx +6 -3
- package/src/components/layout/floating-pane.tsx +3 -2
- package/src/components/layout/pane/content.tsx +20 -8
- package/src/components/layout/pane/footer/registration.tsx +6 -2
- package/src/components/layout/pane/frame.tsx +4 -4
- package/src/components/layout/pane/header.tsx +40 -4
- package/src/components/layout/pane/index.tsx +3 -1
- package/src/components/layout/pane/sizing.test.ts +43 -0
- package/src/components/layout/pane/sizing.ts +13 -4
- package/src/components/layout/shell/cursor-occlusion.ts +196 -0
- package/src/components/layout/shell/drag/runtime.ts +9 -2
- package/src/components/layout/shell/index.test.tsx +92 -2
- package/src/components/layout/shell/index.tsx +37 -2
- package/src/components/layout/shell/layout-state.test.ts +33 -0
- package/src/components/layout/shell/layout-state.ts +4 -9
- package/src/components/layout/shell/pane/layers.tsx +8 -2
- package/src/components/layout/shell/visible-layout.ts +17 -0
- package/src/components/layout/status-bar.test.tsx +6 -1
- package/src/components/layout/status-bar.tsx +5 -1
- package/src/components/onboarding/onboarding-steps.tsx +8 -28
- package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
- package/src/components/onboarding/onboarding-wizard.tsx +2 -4
- package/src/components/onboarding/portfolio-step/index.tsx +0 -12
- package/src/components/onboarding/portfolio-step/types.ts +1 -3
- package/src/components/onboarding/wizard-keyboard.ts +2 -5
- package/src/components/table-view-shared.tsx +12 -4
- package/src/components/ui/data-table/opentui/index.tsx +25 -12
- package/src/components/ui/data-table/types.ts +4 -0
- package/src/components/ui/fields.tsx +10 -3
- package/src/components/ui/page-stack-view.tsx +6 -2
- package/src/components/ui/table-layout.test.ts +25 -0
- package/src/components/ui/table-layout.ts +22 -6
- package/src/components/ui/tabs.tsx +26 -10
- package/src/components/ui/ui.test.tsx +87 -0
- package/src/core/state/app/layout-reducer.ts +2 -2
- package/src/plugins/builtin/analytics/index.test.tsx +6 -2
- package/src/plugins/builtin/analytics/pane-model.ts +43 -8
- package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
- package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
- package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
- package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
- package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
- package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
- package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
- package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
- package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
- package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
- package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
- package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
- package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
- package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
- package/src/plugins/builtin/layout-manager/index.tsx +5 -1
- package/src/plugins/builtin/options/view.test.tsx +141 -4
- package/src/plugins/builtin/options/view.tsx +76 -15
- package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
- package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
- package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
- package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/ibkr/broker-adapter.ts +19 -0
- package/src/plugins/ibkr/config.test.ts +31 -2
- package/src/plugins/ibkr/config.ts +9 -2
- package/src/plugins/ibkr/flex/index.test.ts +18 -9
- package/src/plugins/ibkr/flex/index.ts +30 -0
- package/src/plugins/ibkr/flex/statement-client.ts +68 -14
- package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
- package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
- package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
- package/src/plugins/pane-manager/gridlock.ts +13 -5
- package/src/plugins/pane-manager/layout-state.ts +25 -0
- package/src/plugins/pane-manager.test.ts +33 -0
- package/src/plugins/pane-manager.ts +6 -1
- package/src/plugins/prediction-markets/chart.tsx +10 -4
- package/src/plugins/prediction-markets/detail/book.tsx +141 -96
- package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
- package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
- package/src/plugins/prediction-markets/detail.test.tsx +50 -0
- package/src/plugins/prediction-markets/pane.test.tsx +43 -0
- package/src/plugins/prediction-markets/pane.tsx +50 -22
- package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
- package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
- package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
- package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
- package/src/renderers/electrobun/view/host/input.tsx +115 -18
- package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
- package/src/renderers/electrobun/view/styles.css +11 -0
- package/src/renderers/opentui/host.tsx +17 -1
- package/src/renderers/opentui/ui-host.tsx +57 -25
- package/src/sources/provider-router/batches.ts +16 -2
- package/src/sources/provider-router/financial-routes.ts +5 -0
- package/src/sources/provider-router/financials.ts +11 -23
- package/src/sources/provider-router/index.test.ts +189 -0
- package/src/sources/provider-router/primary.ts +3 -2
- package/src/sources/sec-edgar.test.ts +83 -0
- package/src/sources/sec-edgar.ts +231 -0
- package/src/sources/yahoo-finance.test.ts +51 -0
- package/src/sources/yahoo-finance.ts +49 -1
- package/src/state/app/context/index.test.tsx +5 -8
- package/src/types/broker.ts +1 -0
- package/src/types/config.ts +10 -0
- package/src/types/trading.ts +2 -0
- package/src/ui/host.tsx +16 -0
- package/src/ui/index.tsx +2 -0
- package/src/utils/financial-statements.ts +23 -0
- package/src/version.ts +1 -1
- 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
|
|
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
|
-
|
|
211
|
+
scrollToLeft(activeLeft);
|
|
201
212
|
} else if (activeRight > currentRight) {
|
|
202
|
-
|
|
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
|
|
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
|
-
| (
|
|
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,
|
|
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:
|
|
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("
|
|
330
|
-
expect(frame).toContain("Source Flex Mar
|
|
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
|
|
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(
|
|
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 (
|
|
240
|
+
if (accountFreshness) {
|
|
206
241
|
rows.push({
|
|
207
|
-
id: "
|
|
208
|
-
label: "
|
|
209
|
-
value:
|
|
242
|
+
id: "account-freshness",
|
|
243
|
+
label: "As Of",
|
|
244
|
+
value: accountFreshness,
|
|
210
245
|
color: colors.textDim,
|
|
211
246
|
});
|
|
212
247
|
}
|
|
@@ -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
|
+
}
|