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
@@ -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
- const enterInteractive = () => {
67
+ useEffect(() => {
68
+ onCaptureRef.current = onCapture;
69
+ }, [onCapture]);
70
+
71
+ const enterInteractive = useCallback(() => {
65
72
  if (!interactive) {
66
73
  setInteractive(true);
67
- onCapture(true);
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
- onCapture(false);
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
- exitInteractive();
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 maxExpVisible = Math.max(Math.floor((innerWidth - 14) / 13), 3);
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 flexGrow={1} height={1}>
265
+ <Box width={expirationTabsWidth} height={1} overflow="hidden">
213
266
  <Tabs
214
- tabs={visibleExps.map((ts, i) => ({
267
+ tabs={chain.expirationDates.map((ts, i) => ({
215
268
  label: formatExpDate(ts),
216
- value: String(expStart + i),
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 15");
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: account.updatedAt
76
- ? `Flex ${new Date(account.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
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.updatedAt ?? 0) - (left.updatedAt ?? 0));
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(totals.totalMktValue), tone: "value", bold: true },
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) => ({