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
|
@@ -6,6 +6,7 @@ import { isPlainKey } from "../../../utils/keyboard";
|
|
|
6
6
|
import { formatExpDate, resolveOptionsTarget } from "../../../utils/options";
|
|
7
7
|
import { useOptionsQuery, useResolvedEntryValue } from "../../../market-data/hooks";
|
|
8
8
|
import { DataTableView, Spinner, Tabs, usePaneFooter, type DataTableKeyEvent } from "../../../components";
|
|
9
|
+
import { useShortcut } from "../../../react/input";
|
|
9
10
|
import {
|
|
10
11
|
OPTION_COLUMNS,
|
|
11
12
|
buildStrikeList,
|
|
@@ -26,6 +27,7 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
26
27
|
const [scrollToIndexAlign, setScrollToIndexAlign] = useState<"nearest" | "center">("nearest");
|
|
27
28
|
const [interactive, setInteractive] = useState(false);
|
|
28
29
|
const userSelectedStrikeRef = useRef(false);
|
|
30
|
+
const onCaptureRef = useRef(onCapture);
|
|
29
31
|
const target = resolveOptionsTarget(ticker);
|
|
30
32
|
const isOpt = target?.isOptionTicker ?? false;
|
|
31
33
|
const parsed = target?.parsedOption ?? null;
|
|
@@ -53,6 +55,7 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
53
55
|
);
|
|
54
56
|
const expirationChain = useResolvedEntryValue(expirationChainEntry);
|
|
55
57
|
const chain = expirationChain ?? initialChain;
|
|
58
|
+
const expirationCount = chain?.expirationDates.length ?? 0;
|
|
56
59
|
const loading = (initialChainEntry?.phase === "loading" || initialChainEntry?.phase === "refreshing") && !chain
|
|
57
60
|
|| (expirationChainEntry?.phase === "loading" || expirationChainEntry?.phase === "refreshing");
|
|
58
61
|
const error = initialChainEntry?.phase === "error"
|
|
@@ -61,24 +64,34 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
61
64
|
? expirationChainEntry.error?.message ?? "Failed to load options"
|
|
62
65
|
: null;
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onCaptureRef.current = onCapture;
|
|
69
|
+
}, [onCapture]);
|
|
70
|
+
|
|
71
|
+
const enterInteractive = useCallback(() => {
|
|
65
72
|
if (!interactive) {
|
|
66
73
|
setInteractive(true);
|
|
67
|
-
|
|
74
|
+
onCaptureRef.current(true);
|
|
68
75
|
}
|
|
69
|
-
};
|
|
76
|
+
}, [interactive]);
|
|
70
77
|
|
|
71
|
-
const exitInteractive = () => {
|
|
78
|
+
const exitInteractive = useCallback(() => {
|
|
72
79
|
if (interactive) {
|
|
73
80
|
setInteractive(false);
|
|
74
|
-
|
|
81
|
+
onCaptureRef.current(false);
|
|
75
82
|
}
|
|
76
|
-
};
|
|
83
|
+
}, [interactive]);
|
|
84
|
+
|
|
85
|
+
const selectAdjacentExpiration = useCallback((offset: -1 | 1) => {
|
|
86
|
+
if (expirationCount === 0) return;
|
|
87
|
+
setExpIdx((index) => Math.max(0, Math.min(index + offset, expirationCount - 1)));
|
|
88
|
+
}, [expirationCount]);
|
|
77
89
|
|
|
78
90
|
useEffect(() => {
|
|
79
91
|
userSelectedStrikeRef.current = false;
|
|
80
92
|
setScrollToIndexAlign("nearest");
|
|
81
|
-
|
|
93
|
+
setInteractive(false);
|
|
94
|
+
onCaptureRef.current(false);
|
|
82
95
|
setExpIdx(0);
|
|
83
96
|
setStrikeIdx(0);
|
|
84
97
|
}, [effectiveTicker]);
|
|
@@ -154,6 +167,37 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
154
167
|
setAutoScrollVersion((version) => version + 1);
|
|
155
168
|
}, [expIdx, financials?.quote?.price, parsed?.strike, strikes]);
|
|
156
169
|
|
|
170
|
+
useShortcut((event) => {
|
|
171
|
+
if (event.defaultPrevented || event.propagationStopped || event.targetEditable) return;
|
|
172
|
+
if (event.ctrl || event.meta || event.alt || event.shift) return;
|
|
173
|
+
|
|
174
|
+
const isEnter = event.name === "enter" || event.name === "return";
|
|
175
|
+
const isEscape = event.name === "escape" || event.name === "esc";
|
|
176
|
+
if (isEnter && !interactive) {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
event.stopPropagation();
|
|
179
|
+
enterInteractive();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (isEscape && interactive) {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.stopPropagation();
|
|
185
|
+
exitInteractive();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (interactive && isPlainKey(event, "h", "left")) {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
event.stopPropagation();
|
|
191
|
+
selectAdjacentExpiration(-1);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (interactive && isPlainKey(event, "l", "right")) {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
event.stopPropagation();
|
|
197
|
+
selectAdjacentExpiration(1);
|
|
198
|
+
}
|
|
199
|
+
}, { enabled: focused, phase: "before" });
|
|
200
|
+
|
|
157
201
|
const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
158
202
|
const isEnter = event.name === "enter" || event.name === "return";
|
|
159
203
|
|
|
@@ -169,6 +213,18 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
169
213
|
exitInteractive();
|
|
170
214
|
return true;
|
|
171
215
|
}
|
|
216
|
+
if (interactive && isPlainKey(event, "h", "left")) {
|
|
217
|
+
event.preventDefault?.();
|
|
218
|
+
event.stopPropagation?.();
|
|
219
|
+
selectAdjacentExpiration(-1);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (interactive && isPlainKey(event, "l", "right")) {
|
|
223
|
+
event.preventDefault?.();
|
|
224
|
+
event.stopPropagation?.();
|
|
225
|
+
selectAdjacentExpiration(1);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
172
228
|
|
|
173
229
|
if (isPlainKey(event, "j", "down")) {
|
|
174
230
|
if (strikes.length === 0) return true;
|
|
@@ -190,30 +246,27 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
190
246
|
}
|
|
191
247
|
|
|
192
248
|
return false;
|
|
193
|
-
}, [enterInteractive, exitInteractive, interactive, strikes.length]);
|
|
249
|
+
}, [enterInteractive, exitInteractive, interactive, selectAdjacentExpiration, strikes.length]);
|
|
194
250
|
|
|
195
251
|
if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view options.</Text>;
|
|
196
252
|
if (loading && !chain) return <Spinner label="Loading options chain..." />;
|
|
197
253
|
if (error) return <Text fg={colors.textDim}>{error}</Text>;
|
|
198
254
|
if (!chain || chain.expirationDates.length === 0) return <Text fg={colors.textDim}>No options available for {effectiveTicker}.</Text>;
|
|
199
255
|
|
|
200
|
-
const innerWidth = Math.max(width - 4, 60);
|
|
201
256
|
const posShares = isOpt && parsed
|
|
202
257
|
? ticker.metadata.positions.reduce((sum, p) => sum + p.shares, 0)
|
|
203
258
|
: 0;
|
|
204
|
-
const
|
|
205
|
-
const expStart = Math.max(0, Math.min(expIdx - Math.floor(maxExpVisible / 2), chain.expirationDates.length - maxExpVisible));
|
|
206
|
-
const visibleExps = chain.expirationDates.slice(expStart, expStart + maxExpVisible);
|
|
259
|
+
const expirationTabsWidth = Math.max(width - 7 - (loading ? 2 : 0), 8);
|
|
207
260
|
|
|
208
261
|
return (
|
|
209
262
|
<Box flexDirection="column" flexGrow={1} paddingX={1} onMouseDown={() => { if (!interactive) enterInteractive(); }}>
|
|
210
263
|
<Box flexDirection="row" height={1} gap={1}>
|
|
211
264
|
<Text fg={colors.textDim}>Exp:</Text>
|
|
212
|
-
<Box
|
|
265
|
+
<Box width={expirationTabsWidth} height={1} overflow="hidden">
|
|
213
266
|
<Tabs
|
|
214
|
-
tabs={
|
|
267
|
+
tabs={chain.expirationDates.map((ts, i) => ({
|
|
215
268
|
label: formatExpDate(ts),
|
|
216
|
-
value: String(
|
|
269
|
+
value: String(i),
|
|
217
270
|
}))}
|
|
218
271
|
activeValue={String(expIdx)}
|
|
219
272
|
onSelect={(value) => {
|
|
@@ -223,6 +276,8 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
223
276
|
compact
|
|
224
277
|
variant="bare"
|
|
225
278
|
focused={focused && interactive}
|
|
279
|
+
keyboardNavigation={false}
|
|
280
|
+
scrollId="options-expiration-tabs-scroll"
|
|
226
281
|
/>
|
|
227
282
|
</Box>
|
|
228
283
|
{loading && <Spinner />}
|
|
@@ -255,14 +310,20 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
|
|
|
255
310
|
setStrikeIdx(index);
|
|
256
311
|
}}
|
|
257
312
|
onRootKeyDown={handleTableKeyDown}
|
|
313
|
+
headerScrollId="options-table-header-scroll"
|
|
314
|
+
bodyScrollId="options-table-body-scroll"
|
|
258
315
|
columns={optionColumns}
|
|
259
316
|
items={rows}
|
|
260
317
|
sortColumnId={null}
|
|
261
318
|
sortDirection="asc"
|
|
262
319
|
onHeaderClick={() => {}}
|
|
320
|
+
onTableMouseDown={enterInteractive}
|
|
263
321
|
getItemKey={(row) => String(row.strike)}
|
|
264
322
|
renderCell={renderOptionCell}
|
|
265
323
|
emptyStateTitle="No strikes available."
|
|
324
|
+
columnGap={0}
|
|
325
|
+
horizontalPadding={0}
|
|
326
|
+
fillAvailableWidth={false}
|
|
266
327
|
scrollToIndex={strikeIdx}
|
|
267
328
|
scrollToIndexAlign={scrollToIndexAlign}
|
|
268
329
|
scrollToIndexVersion={autoScrollVersion}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { BrokerAccount } from "../../../types/trading";
|
|
3
3
|
import type { PortfolioSummaryTotals } from "./metrics";
|
|
4
|
-
import { resolvePortfolioAccountMetrics } from "./account-metrics";
|
|
4
|
+
import { resolvePortfolioAccountMetrics, resolvePortfolioMarketValue } from "./account-metrics";
|
|
5
5
|
|
|
6
6
|
function createTotals(overrides: Partial<PortfolioSummaryTotals> = {}): PortfolioSummaryTotals {
|
|
7
7
|
return {
|
|
@@ -48,3 +48,28 @@ describe("resolvePortfolioAccountMetrics", () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("resolvePortfolioMarketValue", () => {
|
|
53
|
+
test("uses explicit broker gross position value when available", () => {
|
|
54
|
+
const account: BrokerAccount = {
|
|
55
|
+
accountId: "DU12345",
|
|
56
|
+
name: "DU12345",
|
|
57
|
+
grossPositionValue: 12_345,
|
|
58
|
+
netLiquidation: 20_000,
|
|
59
|
+
totalCashValue: 7_000,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
expect(resolvePortfolioMarketValue(createTotals({ totalMktValue: 10_000 }), account)).toBe(12_345);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("does not derive market value from account cash and net liquidation", () => {
|
|
66
|
+
const account: BrokerAccount = {
|
|
67
|
+
accountId: "DU12345",
|
|
68
|
+
name: "DU12345",
|
|
69
|
+
netLiquidation: 20_000,
|
|
70
|
+
totalCashValue: 7_000,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
expect(resolvePortfolioMarketValue(createTotals({ totalMktValue: 10_000 }), account)).toBe(10_000);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -17,6 +17,20 @@ function percentChange(value: number, previousValue: number): number {
|
|
|
17
17
|
return previousValue !== 0 ? (value / previousValue) * 100 : 0;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function resolveBrokerPortfolioMarketValue(account?: BrokerAccount | null): number | null {
|
|
21
|
+
if (finiteNumber(account?.grossPositionValue)) {
|
|
22
|
+
return account.grossPositionValue;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolvePortfolioMarketValue(
|
|
28
|
+
totals: PortfolioSummaryTotals,
|
|
29
|
+
account?: BrokerAccount | null,
|
|
30
|
+
): number {
|
|
31
|
+
return resolveBrokerPortfolioMarketValue(account) ?? totals.totalMktValue;
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
export function resolvePortfolioAccountMetrics(
|
|
21
35
|
totals: PortfolioSummaryTotals,
|
|
22
36
|
account?: BrokerAccount | null,
|
|
@@ -40,6 +40,7 @@ describe("buildPortfolioSummarySegments", () => {
|
|
|
40
40
|
accountId: "DU12345",
|
|
41
41
|
name: "DU12345",
|
|
42
42
|
netLiquidation: 125000,
|
|
43
|
+
grossPositionValue: 175000,
|
|
43
44
|
totalCashValue: -50000,
|
|
44
45
|
settledCash: -45000,
|
|
45
46
|
availableFunds: 12000,
|
|
@@ -57,6 +58,16 @@ describe("buildPortfolioSummarySegments", () => {
|
|
|
57
58
|
expect(segments.map((segment) => segment.id)).toEqual(["netliq", "val"]);
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
test("uses broker gross position value for broker portfolio value", () => {
|
|
62
|
+
const segments = buildPortfolioSummarySegments({
|
|
63
|
+
totals,
|
|
64
|
+
accountState: { account, sourceLabel: "Live" },
|
|
65
|
+
widthBudget: 80,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(segments.find((segment) => segment.id === "val")?.parts[1]?.text).toBe("175k");
|
|
69
|
+
});
|
|
70
|
+
|
|
60
71
|
test("drops low-priority broker segments before required ones", () => {
|
|
61
72
|
const segments = buildPortfolioSummarySegments({
|
|
62
73
|
totals,
|
|
@@ -85,6 +96,18 @@ describe("buildPortfolioSummarySegments", () => {
|
|
|
85
96
|
expect(segments.map((segment) => segment.id)).toContain("source");
|
|
86
97
|
});
|
|
87
98
|
|
|
99
|
+
test("shows account status when a broker portfolio has positions but no account snapshot", () => {
|
|
100
|
+
const segments = buildPortfolioSummarySegments({
|
|
101
|
+
totals,
|
|
102
|
+
accountState: null,
|
|
103
|
+
accountStatusText: "Acct missing",
|
|
104
|
+
widthBudget: 80,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(segments.map((segment) => segment.id)).toContain("account-status");
|
|
108
|
+
expect(segments.find((segment) => segment.id === "account-status")?.parts[0]?.text).toBe("Acct missing");
|
|
109
|
+
});
|
|
110
|
+
|
|
88
111
|
test("treats c as the cash drawer shortcut only when the drawer is available", () => {
|
|
89
112
|
expect(shouldToggleCashMarginDrawer("c", true)).toBe(true);
|
|
90
113
|
expect(shouldToggleCashMarginDrawer("c", false)).toBe(false);
|
|
@@ -294,6 +294,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
294
294
|
|
|
295
295
|
const summaryFooterInfo = useMemo(() => buildPortfolioFooterSegments({
|
|
296
296
|
accountState: accountState ? { account: accountState.account, sourceLabel: accountState.sourceLabel } : null,
|
|
297
|
+
accountStatusText: isPortfolioTab && currentPortfolio?.brokerInstanceId && !accountState ? "Acct missing" : undefined,
|
|
297
298
|
activeCollectionId,
|
|
298
299
|
baseCurrency: config.baseCurrency,
|
|
299
300
|
exchangeRates: effectiveExchangeRates,
|
|
@@ -306,6 +307,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
306
307
|
}), [
|
|
307
308
|
accountState,
|
|
308
309
|
activeCollectionId,
|
|
310
|
+
currentPortfolio?.brokerInstanceId,
|
|
309
311
|
effectiveExchangeRates,
|
|
310
312
|
financialsMap,
|
|
311
313
|
isPortfolioTab,
|
|
@@ -112,6 +112,7 @@ describe("resolvePortfolioAccountState", () => {
|
|
|
112
112
|
currency: "USD",
|
|
113
113
|
source: "flex",
|
|
114
114
|
updatedAt: new Date("2026-05-15T05:02:17.000Z").getTime(),
|
|
115
|
+
asOfDate: "2026-05-14",
|
|
115
116
|
netLiquidation: 123456,
|
|
116
117
|
}],
|
|
117
118
|
},
|
|
@@ -121,6 +122,63 @@ describe("resolvePortfolioAccountState", () => {
|
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
expect(accountState?.account.netLiquidation).toBe(123456);
|
|
124
|
-
expect(accountState?.sourceLabel).toBe("Flex May
|
|
125
|
+
expect(accountState?.sourceLabel).toBe("Flex May 14");
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
test("uses the account as-of date when choosing the freshest cached Flex account", () => {
|
|
129
|
+
const accountState = resolvePortfolioAccountState({
|
|
130
|
+
id: "broker:ibkr-old:U12345",
|
|
131
|
+
name: "U12345",
|
|
132
|
+
currency: "USD",
|
|
133
|
+
brokerId: "ibkr",
|
|
134
|
+
brokerInstanceId: "ibkr-old",
|
|
135
|
+
brokerAccountId: "U12345",
|
|
136
|
+
}, {
|
|
137
|
+
config: {
|
|
138
|
+
brokerInstances: [
|
|
139
|
+
{
|
|
140
|
+
id: "ibkr-old",
|
|
141
|
+
label: "IBKR Old",
|
|
142
|
+
brokerType: "ibkr",
|
|
143
|
+
enabled: true,
|
|
144
|
+
config: {},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "ibkr-new",
|
|
148
|
+
label: "IBKR New",
|
|
149
|
+
brokerType: "ibkr",
|
|
150
|
+
enabled: true,
|
|
151
|
+
config: {},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
} as any,
|
|
155
|
+
brokerAccounts: {
|
|
156
|
+
"ibkr-old": [{
|
|
157
|
+
accountId: "U12345",
|
|
158
|
+
name: "U12345",
|
|
159
|
+
currency: "USD",
|
|
160
|
+
source: "flex",
|
|
161
|
+
updatedAt: new Date("2026-06-16T10:00:00.000Z").getTime(),
|
|
162
|
+
asOfDate: "2026-06-15",
|
|
163
|
+
netLiquidation: 100000,
|
|
164
|
+
}],
|
|
165
|
+
"ibkr-new": [{
|
|
166
|
+
accountId: "U12345",
|
|
167
|
+
name: "U12345",
|
|
168
|
+
currency: "USD",
|
|
169
|
+
source: "flex",
|
|
170
|
+
updatedAt: new Date("2026-06-15T10:00:00.000Z").getTime(),
|
|
171
|
+
asOfDate: "2026-06-16",
|
|
172
|
+
netLiquidation: 200000,
|
|
173
|
+
}],
|
|
174
|
+
},
|
|
175
|
+
}, {
|
|
176
|
+
status: null,
|
|
177
|
+
accounts: [],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(accountState?.account.netLiquidation).toBe(200000);
|
|
181
|
+
expect(accountState?.sourceLabel).toBe("Flex Jun 16");
|
|
182
|
+
});
|
|
183
|
+
|
|
126
184
|
});
|
|
@@ -9,7 +9,7 @@ import type { Portfolio, TickerRecord } from "../../../../types/ticker";
|
|
|
9
9
|
import type { BrokerAccount, BrokerCashBalance } from "../../../../types/trading";
|
|
10
10
|
import { formatCompact, formatPercentRaw } from "../../../../utils/format";
|
|
11
11
|
import { getBrokerInstance } from "../../../../utils/broker-instances";
|
|
12
|
-
import { resolvePortfolioAccountMetrics } from "../account-metrics";
|
|
12
|
+
import { resolvePortfolioAccountMetrics, resolvePortfolioMarketValue } from "../account-metrics";
|
|
13
13
|
import { calculatePortfolioSummaryTotals, type PortfolioSummaryTotals } from "./totals";
|
|
14
14
|
import { getMostRecentQuoteUpdate } from "../../../../market-data/quotes/time";
|
|
15
15
|
|
|
@@ -54,6 +54,22 @@ function formatSignedCompact(value: number): string {
|
|
|
54
54
|
return `${value >= 0 ? "+" : ""}${formatCompact(value)}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function formatMonthDay(date: Date): string {
|
|
58
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseIsoDateAsLocalDate(value: string): Date | null {
|
|
62
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
63
|
+
if (!match) return null;
|
|
64
|
+
const [, year, month, day] = match;
|
|
65
|
+
return new Date(Number(year), Number(month) - 1, Number(day));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getAccountFreshnessTime(account: BrokerAccount): number {
|
|
69
|
+
const asOfDate = account.asOfDate ? parseIsoDateAsLocalDate(account.asOfDate) : null;
|
|
70
|
+
return asOfDate?.getTime() ?? account.updatedAt ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
function fitSummarySegments(candidates: PortfolioSummarySegment[], widthBudget: number): PortfolioSummarySegment[] {
|
|
58
74
|
const fitted: PortfolioSummarySegment[] = [];
|
|
59
75
|
let used = 0;
|
|
@@ -71,9 +87,12 @@ function formatSourceBadge(account: BrokerAccount, liveGateway: boolean): { labe
|
|
|
71
87
|
return { label: "Live", kind: "live" };
|
|
72
88
|
}
|
|
73
89
|
if (account.source === "flex") {
|
|
90
|
+
const asOfDate = account.asOfDate ? parseIsoDateAsLocalDate(account.asOfDate) : null;
|
|
74
91
|
return {
|
|
75
|
-
label:
|
|
76
|
-
? `Flex ${
|
|
92
|
+
label: asOfDate
|
|
93
|
+
? `Flex ${formatMonthDay(asOfDate)}`
|
|
94
|
+
: account.updatedAt
|
|
95
|
+
? `Flex ${formatMonthDay(new Date(account.updatedAt))}`
|
|
77
96
|
: "Flex",
|
|
78
97
|
kind: "flex",
|
|
79
98
|
};
|
|
@@ -120,7 +139,7 @@ function findPortfolioAccount(
|
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
function sortAccountsByFreshness(accounts: BrokerAccount[]): BrokerAccount[] {
|
|
123
|
-
return [...accounts].sort((left, right) => (right
|
|
142
|
+
return [...accounts].sort((left, right) => getAccountFreshnessTime(right) - getAccountFreshnessTime(left));
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
export function resolvePortfolioAccountState(
|
|
@@ -165,16 +184,19 @@ export function resolvePortfolioAccountState(
|
|
|
165
184
|
export function buildPortfolioSummarySegments({
|
|
166
185
|
totals,
|
|
167
186
|
accountState,
|
|
187
|
+
accountStatusText,
|
|
168
188
|
widthBudget,
|
|
169
189
|
refreshText,
|
|
170
190
|
}: {
|
|
171
191
|
totals: PortfolioSummaryTotals;
|
|
172
192
|
accountState: PortfolioSummaryAccountState | null;
|
|
193
|
+
accountStatusText?: string;
|
|
173
194
|
widthBudget: number;
|
|
174
195
|
refreshText?: string;
|
|
175
196
|
}): PortfolioSummarySegment[] {
|
|
176
197
|
const candidates: PortfolioSummarySegment[] = [];
|
|
177
198
|
const accountMetrics = resolvePortfolioAccountMetrics(totals, accountState?.account);
|
|
199
|
+
const totalMarketValue = resolvePortfolioMarketValue(totals, accountState?.account);
|
|
178
200
|
|
|
179
201
|
if (accountState?.account.netLiquidation != null) {
|
|
180
202
|
candidates.push(createSummarySegment("netliq", [
|
|
@@ -185,7 +207,7 @@ export function buildPortfolioSummarySegments({
|
|
|
185
207
|
|
|
186
208
|
candidates.push(createSummarySegment("val", [
|
|
187
209
|
{ text: "Val", tone: "label" },
|
|
188
|
-
{ text: formatCompact(
|
|
210
|
+
{ text: formatCompact(totalMarketValue), tone: "value", bold: true },
|
|
189
211
|
]));
|
|
190
212
|
|
|
191
213
|
if (accountState?.account.totalCashValue != null) {
|
|
@@ -247,6 +269,12 @@ export function buildPortfolioSummarySegments({
|
|
|
247
269
|
return fitSummarySegments([...candidates, ...brokerSegments], widthBudget);
|
|
248
270
|
}
|
|
249
271
|
|
|
272
|
+
if (accountStatusText) {
|
|
273
|
+
candidates.push(createSummarySegment("account-status", [
|
|
274
|
+
{ text: accountStatusText, tone: "muted" },
|
|
275
|
+
]));
|
|
276
|
+
}
|
|
277
|
+
|
|
250
278
|
if (refreshText) {
|
|
251
279
|
candidates.push(createSummarySegment("refresh", [
|
|
252
280
|
{ text: refreshText, tone: "muted" },
|
|
@@ -258,6 +286,7 @@ export function buildPortfolioSummarySegments({
|
|
|
258
286
|
|
|
259
287
|
export function buildPortfolioFooterSegments({
|
|
260
288
|
accountState,
|
|
289
|
+
accountStatusText,
|
|
261
290
|
activeCollectionId,
|
|
262
291
|
baseCurrency,
|
|
263
292
|
exchangeRates,
|
|
@@ -269,6 +298,7 @@ export function buildPortfolioFooterSegments({
|
|
|
269
298
|
width,
|
|
270
299
|
}: {
|
|
271
300
|
accountState: PortfolioSummaryAccountState | null;
|
|
301
|
+
accountStatusText?: string;
|
|
272
302
|
activeCollectionId: string | null;
|
|
273
303
|
baseCurrency: string;
|
|
274
304
|
exchangeRates: Map<string, number>;
|
|
@@ -319,6 +349,7 @@ export function buildPortfolioFooterSegments({
|
|
|
319
349
|
return buildPortfolioSummarySegments({
|
|
320
350
|
totals,
|
|
321
351
|
accountState,
|
|
352
|
+
accountStatusText,
|
|
322
353
|
widthBudget: Math.max(16, width - 14),
|
|
323
354
|
refreshText,
|
|
324
355
|
}).map((segment) => ({
|