gloomberb 0.16.2 → 0.16.3

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 (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
@@ -26,8 +26,11 @@ import {
26
26
  formatMaybePercent,
27
27
  formatMoneyCompact,
28
28
  formatSignedCompact,
29
+ holderStakeMarketCap,
30
+ holderValueCurrency,
29
31
  resolveHolderOwnershipPercent,
30
32
  } from "./format";
33
+ import { holderShareBasisMarker } from "./report-header";
31
34
  import {
32
35
  buildColumns,
33
36
  hasHolderChanges,
@@ -46,8 +49,8 @@ import { useSampledValue, useTickerQuoteStream } from "../../../state/hooks/live
46
49
 
47
50
  const HOLDER_MARKET_CAP_SAMPLE_MS = 5_000;
48
51
 
49
- /** The "13F" badge: the label and its padding. */
50
- const FUND_BADGE_WIDTH = 5;
52
+ /** The width of a three-letter badge ("13F", "ADR"): the label and its padding. */
53
+ const BADGE_WIDTH = 5;
51
54
 
52
55
  export function HoldersView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
53
56
  const { nativePaneChrome } = useUiCapabilities();
@@ -70,13 +73,14 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
70
73
  // refresh keeps the last holders; the failure goes to the footer.
71
74
  const { data, loading, error, reload } = useAsyncResource<HolderData>(loadHolders, { keepPreviousData: true });
72
75
 
73
- const currency = data?.currency || ticker?.metadata.currency || "USD";
76
+ // The values' own currency: dollars from the 13F filings on a London line, not its pence.
77
+ const currency = holderValueCurrency(data, ticker?.metadata.currency || "USD")!;
74
78
  // A stake without a reported percentage is the holding's value over the
75
- // market cap. The stream keeps the cap current, about once a second; the
76
- // stakes follow it at most every few seconds so ticks do not re-sort them.
79
+ // market cap, when both are in one currency. The stream keeps the cap
80
+ // current, about once a second; the stakes follow it at most every few
81
+ // seconds so ticks do not re-sort them.
77
82
  useTickerQuoteStream(ticker ? symbol : null, ticker, { surface: "detail", visible: false, weight: 30 });
78
- const quoteMarketCap = financials?.quote?.marketCap;
79
- const liveMarketCap = financials?.quote?.currency && financials.quote.currency !== currency ? undefined : quoteMarketCap;
83
+ const liveMarketCap = holderStakeMarketCap(financials?.quote?.marketCap, financials?.quote?.currency, currency);
80
84
  const marketCap = useSampledValue(liveMarketCap, HOLDER_MARKET_CAP_SAMPLE_MS, `${symbol ?? ""}:${currency}`);
81
85
  const rows = useMemo(() => buildRows(data), [data]);
82
86
  const sortedRows = useMemo(() => sortRows(rows, sortPreference, marketCap), [marketCap, rows, sortPreference]);
@@ -216,17 +220,22 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
216
220
  switch (column.id) {
217
221
  case "holder": {
218
222
  const color = rowState.selected ? colors.selectedText : colors.textBright;
219
- if (!fundMatches.has(row.id)) return { text: row.name, color, attributes: TextAttributes.BOLD };
220
- // A fund with a 13F opens on Enter or o. The name gives way first, so
221
- // the marker survives a narrow column.
223
+ // ADR: a position held as receipts on a home line's list. 13F: a fund
224
+ // whose filing opens on Enter or o.
225
+ const receipts = holderShareBasisMarker(row);
226
+ const fund = fundMatches.has(row.id);
227
+ if (!receipts && !fund) return { text: row.name, color, attributes: TextAttributes.BOLD };
228
+ const badges = (receipts ? 1 : 0) + (fund ? 1 : 0);
229
+ // The name gives way first, so the markers survive a narrow column.
222
230
  return {
223
- text: `${row.name} 13F`,
231
+ text: [row.name, receipts, fund ? "13F" : ""].filter(Boolean).join(" "),
224
232
  content: (
225
233
  <Box flexDirection="row" gap={1} width={column.width} overflow="hidden">
226
234
  <Text fg={color} attributes={TextAttributes.BOLD}>
227
- {clipToDisplayWidth(row.name, Math.max(1, column.width - FUND_BADGE_WIDTH - 1))}
235
+ {clipToDisplayWidth(row.name, Math.max(1, column.width - badges * (BADGE_WIDTH + 1)))}
228
236
  </Text>
229
- <Badge label="13F" tone="accent" />
237
+ {receipts ? <Badge label={receipts} /> : null}
238
+ {fund ? <Badge label="13F" tone="accent" /> : null}
230
239
  </Box>
231
240
  ),
232
241
  };
@@ -1,4 +1,6 @@
1
+ import type { HolderData } from "../../../types/financials";
1
2
  import { currencyUnitLabel, resolveCurrencyUnit } from "../../../utils/currency-units";
3
+ import { adrRatioText } from "../../../utils/depositary-receipt";
2
4
  import { canonicalExchange, isUsListingExchange, resolveExchangeTimeZone } from "../../../utils/exchanges";
3
5
 
4
6
  /**
@@ -12,7 +14,10 @@ export interface HolderReportFacts {
12
14
  /** Left out when the report title already names it. */
13
15
  symbol?: string | null;
14
16
  exchange?: string | null;
17
+ /** The unit the values are in. */
15
18
  currency?: string | null;
19
+ /** The listing's price currency, when the values are in another one. */
20
+ listingCurrency?: string | null;
16
21
  /** Left out when the report's closing line already carries it. */
17
22
  asOf?: string | null;
18
23
  /** Rows printed. */
@@ -46,10 +51,13 @@ function venueLabel(exchange?: string | null): string {
46
51
 
47
52
  /** What the list is, after the listing is named: its unit, date and how much of it is shown. */
48
53
  export function holderListFacts(
49
- facts: Pick<HolderReportFacts, "currency" | "asOf" | "shown" | "reported" | "total">,
54
+ facts: Pick<HolderReportFacts, "currency" | "listingCurrency" | "asOf" | "shown" | "reported" | "total">,
50
55
  ): string[] {
56
+ const unit = currencyUnitLabel(facts.currency);
57
+ // Dollars on a London line: say they are the values' unit, not the listing's.
58
+ const otherUnit = unit && facts.listingCurrency?.trim() && facts.listingCurrency.trim() !== facts.currency?.trim();
51
59
  return [
52
- currencyUnitLabel(facts.currency),
60
+ otherUnit ? `values in ${unit}` : unit,
53
61
  facts.asOf ? `as of ${facts.asOf}` : "",
54
62
  holderCountText(facts.shown, facts.reported, facts.total),
55
63
  ].filter(Boolean);
@@ -69,9 +77,42 @@ export function sharedReportDate(rows: ReadonlyArray<{ reportDate?: unknown }>):
69
77
  return dates.size === 1 && only ? only : null;
70
78
  }
71
79
 
72
- /** How a holder value was reached: reported shares times a current price, not the filing's own value. */
73
- export function holderValueBasis(reportDate: string | null): string {
74
- return `shares reported${reportDate ? ` at ${reportDate}` : ""} x latest price`;
80
+ /**
81
+ * How a holder value was reached: reported shares times the latest price, or,
82
+ * where Gloom Cloud priced the filings themselves, times the security's price
83
+ * at the report date in the values' currency.
84
+ */
85
+ export function holderValueBasis(
86
+ reportDate: string | null,
87
+ basis?: HolderData["valueBasis"] | null,
88
+ valueCurrency?: string | null,
89
+ ): string {
90
+ const shares = `shares reported${reportDate ? ` at ${reportDate}` : ""}`;
91
+ if (basis === "period_end_price") {
92
+ const unit = valueCurrency?.trim();
93
+ return `${shares} x period-end price${unit ? ` (${unit})` : ""}`;
94
+ }
95
+ return `${shares} x latest price`;
96
+ }
97
+
98
+ /** The short marker a holder row carries when its position is held as depositary receipts. */
99
+ export function holderShareBasisMarker(row: { shareBasis?: unknown }): string {
100
+ return row.shareBasis === "depositary_receipt" ? "ADR" : "";
101
+ }
102
+
103
+ /**
104
+ * What the share counts are, when Gloom Cloud says: which rows of a home
105
+ * line are positions in the receipts, or that a receipt line's rows all are.
106
+ */
107
+ export function holderShareBasisNote(
108
+ data: Pick<HolderData, "shareBasis" | "adrRatio">,
109
+ rows: ReadonlyArray<{ shareBasis?: unknown }>,
110
+ ): string | null {
111
+ const ratio = adrRatioText(data.adrRatio);
112
+ const each = ratio ? ` (${ratio})` : "";
113
+ if (rows.some((row) => holderShareBasisMarker(row))) return `ADR: shares held as depositary receipts${each}.`;
114
+ if (rows.length > 0 && data.shareBasis === "depositary_receipt") return `Shares are depositary receipts${each}.`;
115
+ return null;
75
116
  }
76
117
 
77
118
  /** A listing outside the US, by its venue, or by a currency other than the dollar. */
@@ -35,6 +35,7 @@ import { getSharedRegistry } from "../../registry";
35
35
  import { resolveTickerOpenTarget } from "../../../tickers/open-target";
36
36
  import { calculatePortfolioSummaryTotals } from "../portfolio-list/metrics";
37
37
  import { resolvePortfolioNetLiquidation } from "../portfolio-list/account-metrics";
38
+ import { resolvePortfolioCash } from "../portfolio-list/allocation";
38
39
  import {
39
40
  buildTrackedCurrencies,
40
41
  getCollectionTickersFromConfig,
@@ -71,7 +72,7 @@ import {
71
72
  KellySensitivitySection,
72
73
  buildKellyResultItems,
73
74
  } from "./sections";
74
- import { getPortfolioPositionValue, resolveActivePortfolioId } from "./portfolio";
75
+ import { getPortfolioPositionValue, resolveActivePortfolioId, resolveKellyBankroll } from "./portfolio";
75
76
  import { useKellyCommonAssumptions } from "./state";
76
77
 
77
78
  export function KellySizerPane({ focused, width, height }: PaneProps) {
@@ -214,9 +215,11 @@ export function KellySizerPane({ focused, width, height }: PaneProps) {
214
215
  ? portfolioFinancials.get(requestedSymbol!) ?? null
215
216
  : financials;
216
217
  const { accountState } = usePortfolioAccountState(activePortfolio, { brokerAccounts, config });
218
+ // Cash entered by hand counts toward a manual portfolio's bankroll; a broker's is in its Net Liq.
219
+ const manualCash = accountState ? null : resolvePortfolioCash(activePortfolio, null);
217
220
  const trackedCurrencies = useMemo(
218
- () => buildTrackedCurrencies(portfolioTickers, portfolioFinancials, accountState, config.baseCurrency),
219
- [accountState, config.baseCurrency, portfolioFinancials, portfolioTickers],
221
+ () => buildTrackedCurrencies(portfolioTickers, portfolioFinancials, accountState, config.baseCurrency, manualCash?.currency),
222
+ [accountState, config.baseCurrency, manualCash?.currency, portfolioFinancials, portfolioTickers],
220
223
  );
221
224
  const exchangeRates = useFxRatesMap(trackedCurrencies);
222
225
  const portfolioSummary = useMemo(
@@ -237,9 +240,12 @@ export function KellySizerPane({ focused, width, height }: PaneProps) {
237
240
  (value) => convertCurrency(value, accountState?.account.currency ?? "", config.baseCurrency, exchangeRates),
238
241
  accountState?.snapshotBasis,
239
242
  );
240
- const sourceBankroll = netLiquidation != null && Number.isFinite(netLiquidation)
241
- ? netLiquidation
242
- : portfolioSummary.totalMktValue ?? 0;
243
+ const sourceBankroll = resolveKellyBankroll({
244
+ netLiquidation,
245
+ brokerPortfolio: !!activePortfolio?.brokerInstanceId,
246
+ holdings: portfolioSummary.allocationHoldings ?? [],
247
+ cashValue: manualCash ? convertCurrency(manualCash.amount, manualCash.currency, config.baseCurrency, exchangeRates) : null,
248
+ });
243
249
  const sourceCurrentValue = getPortfolioPositionValue({
244
250
  ticker,
245
251
  financials: positionFinancials,
@@ -2,6 +2,7 @@ import type { AppConfig } from "../../../types/config";
2
2
  import type { TickerFinancials } from "../../../types/financials";
3
3
  import type { TickerRecord } from "../../../types/ticker";
4
4
  import { convertCurrency } from "../../../utils/format";
5
+ import { allocationTotal, type AllocationHolding } from "../portfolio-list/allocation";
5
6
  import { getCollectionTypeFromConfig } from "../portfolio-list/pane/data";
6
7
  import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolioMarketValue } from "../portfolio-list/position-metrics";
7
8
 
@@ -41,6 +42,30 @@ export function getPortfolioPositionValue({
41
42
  ?? (metrics.positionCount > 0 ? Number.NaN : 0);
42
43
  }
43
44
 
45
+ /**
46
+ * What the portfolio is worth to bet with: its equity. A broker account's is
47
+ * its Net Liq. Otherwise it is the priced holdings at net value plus the cash
48
+ * entered for the portfolio, the portfolio header's Total. A broker portfolio
49
+ * with neither has no known equity: its positions alone are its gross, which
50
+ * margin puts above the equity, so it gives no bankroll.
51
+ */
52
+ export function resolveKellyBankroll({
53
+ netLiquidation,
54
+ brokerPortfolio,
55
+ holdings,
56
+ cashValue,
57
+ }: {
58
+ netLiquidation: number | null;
59
+ brokerPortfolio: boolean;
60
+ holdings: readonly AllocationHolding[];
61
+ /** Cash entered by hand, in the bankroll currency; NaN when it cannot be converted. */
62
+ cashValue: number | null;
63
+ }): number {
64
+ if (netLiquidation != null && Number.isFinite(netLiquidation)) return netLiquidation;
65
+ if (brokerPortfolio && cashValue == null) return 0;
66
+ return allocationTotal(holdings, cashValue) ?? 0;
67
+ }
68
+
44
69
  export function resolveActivePortfolioId({
45
70
  requestedPortfolioId,
46
71
  activeCollectionId,
@@ -11,6 +11,7 @@ import type { PaneProps } from "../../../types/plugin";
11
11
  import { Box, type ScrollBoxRenderable } from "../../../ui";
12
12
  import { nextHeaderSort, type SortPreference } from "../../../utils/sort-values";
13
13
  import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
14
+ import { useMembersMenu, type MemberList } from "../shared/members-menu";
14
15
  import { cachedChanges, cachedFunds, cachedMembers, loadChanges, loadFunds, loadMembers } from "./client";
15
16
  import { canonicalFund, changeColumns, changeLabel, changeReason, coveredFund, decimal, DEFAULT_SORT, isTab, memberColumns, memberRows, membersTitle, notCoveredMessage, percent, TABS, type MembersTab } from "./model";
16
17
 
@@ -29,6 +30,15 @@ function Holdings({ fund, tab, width, height, focused, active }: ViewProps & { t
29
30
  const movers = tab === "movers";
30
31
  const rows = useMemo(() => memberRows(data?.members ?? [], tab, query, sort), [data, tab, query, sort]);
31
32
  const columns = useMemo(() => memberColumns(width, movers), [width, movers]);
33
+ // Movers is a ranked slice of the fund, not its members, so the entries stay on the Members tab.
34
+ const memberList = useMemo((): MemberList | null => {
35
+ if (!data || tab !== "members") return null;
36
+ const typed = query.trim();
37
+ return { title: fund, watchlistName: typed ? `${fund} ${typed}` : `${fund} members`,
38
+ search: typed ? { query: typed, total: data.members.filter((row) => row.symbol).length } : null,
39
+ members: rows.flatMap((row) => isSectionedItemRow(row) && row.item.symbol ? [{ symbol: row.item.symbol, name: row.item.name }] : []) };
40
+ }, [data, fund, query, rows, tab]);
41
+ useMembersMenu("members:members-menu", memberList);
32
42
  useAutoRefresh(resource.updatedAt, () => { if (active) void resource.load(); });
33
43
  usePaneRefreshKey(() => void resource.reload(), { focused, enabled: active && !search.active });
34
44
  const info = useMemo<PaneFooterSegment[]>(() => {
@@ -42,7 +42,7 @@ function MarketEvidence({ row, data, width, height }: { row: PerpBoardRow; data:
42
42
  ["Contract", [["Contract", `${label(row.contractType)} · ${row.priceMultiplier}× price`], ["Margin", row.marginCurrency], ["Max leverage", row.maxLeverage == null ? "--" : `${row.maxLeverage}×`],
43
43
  ["Margin mode", row.isolatedOnly === null ? "Unspecified" : row.isolatedOnly ? "Isolated only" : "Cross / isolated"], ["Listing", row.delisted ? "Delisted" : "Active"], ["Confidence", label(row.confidence)]]],
44
44
  ];
45
- return <ScrollBox width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
45
+ return <ScrollBox scrollY width={width} height={desktop ? undefined : height} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
46
46
  {groups.map(([title, fields]) => <Fragment key={title}>
47
47
  <SectionHeading title={title} />
48
48
  {fields.map(([name, value, detail]) => <KeyValueRow key={name} labelWidth={25} label={name} value={value} detail={detail} />)}
@@ -139,6 +139,13 @@ export function resolvePortfolioNetLiquidation(
139
139
  return convertAccountValue(account.netLiquidation) + brokerSnapshotDelta(totals, account, basis, convertAccountValue).net;
140
140
  }
141
141
 
142
+ /** Gross exposure per unit of equity; above 1 the account borrows or is short. */
143
+ export function resolvePortfolioLeverage(grossValue: number, netLiquidation: number | null | undefined): number | null {
144
+ return isFiniteNumber(netLiquidation) && netLiquidation > 0 && Number.isFinite(grossValue) && grossValue >= 0
145
+ ? grossValue / netLiquidation
146
+ : null;
147
+ }
148
+
142
149
  export function resolvePortfolioAccountMetrics(
143
150
  totals: PortfolioSummaryTotals,
144
151
  account?: BrokerAccount | null,
@@ -167,9 +174,17 @@ export function resolvePortfolioAccountMetrics(
167
174
  : isFiniteNumber(account?.netLiquidation)
168
175
  ? convertAccountValue(account.netLiquidation) - brokerDailyPnl
169
176
  : null;
177
+ // Without the broker's day, the quotes' day is still a move of the equity,
178
+ // which margin puts below the positions the totals divide by.
179
+ const quotedPreviousEquity = brokerDailyPnl == null && account && isFiniteNumber(account.netLiquidation)
180
+ && Number.isFinite(dailyPnl)
181
+ ? convertAccountValue(account.netLiquidation) + delta.net - dailyPnl
182
+ : null;
170
183
  const dailyPnlPct = previousNetLiquidation != null
171
184
  ? percentChange(dailyPnl, previousNetLiquidation)
172
- : totals.dailyPnlPct;
185
+ : quotedPreviousEquity != null && quotedPreviousEquity > 0
186
+ ? percentChange(dailyPnl, quotedPreviousEquity)
187
+ : totals.dailyPnlPct;
173
188
 
174
189
  const liveUnrealizedPnl = liveTotal(totals, totals.unrealizedPnl);
175
190
  const brokerUnrealizedPnl = liveUnrealizedPnl == null && isFiniteNumber(account?.unrealizedPnl)
@@ -17,6 +17,9 @@ import { formatNumber } from "../../../utils/format";
17
17
  /** The cash line's symbol in targets and reports. */
18
18
  export const CASH_SYMBOL = "CASH";
19
19
 
20
+ /** A broker portfolio without its account: margin can put its positions far above what it is worth. */
21
+ export const BROKER_ACCOUNT_MISSING_NOTE = "The broker account has not synced on this device, so its cash and margin are unknown: the total is the positions alone, not the account's value.";
22
+
20
23
  /** One ticker of a portfolio, valued in the portfolio's currency. */
21
24
  export interface AllocationHolding {
22
25
  symbol: string;
@@ -6,17 +6,16 @@ import type { BrokerAccount } from "../../../types/trading";
6
6
  import { resolvePortfolioAccountState } from "./summary";
7
7
 
8
8
  /**
9
- * The broker account a portfolio was last synced with, as saved on this
10
- * device, for reports that run without a broker connection. The pane reads
11
- * the same account from app state.
9
+ * Every profile's broker accounts as last synced on this device, by profile,
10
+ * for reports and screenshots that run without a broker connection. The pane
11
+ * reads the same accounts from app state.
12
12
  */
13
- export function findCachedPortfolioAccount(
14
- config: AppConfig,
15
- portfolio: Portfolio,
13
+ export function peekCachedBrokerAccounts(
14
+ config: Pick<AppConfig, "brokerInstances">,
16
15
  resources: Pick<AppResourceStorePort, "list"> | null | undefined,
17
- ): BrokerAccount | null {
18
- if (!resources || !portfolio.brokerInstanceId) return null;
16
+ ): Record<string, BrokerAccount[]> {
19
17
  const brokerAccounts: Record<string, BrokerAccount[]> = {};
18
+ if (!resources) return brokerAccounts;
20
19
  for (const instance of config.brokerInstances) {
21
20
  try {
22
21
  const accounts = peekPersistedBrokerAccounts(resources, instance);
@@ -25,9 +24,19 @@ export function findCachedPortfolioAccount(
25
24
  // An unreadable snapshot leaves the portfolio without account figures.
26
25
  }
27
26
  }
27
+ return brokerAccounts;
28
+ }
29
+
30
+ /** The broker account a portfolio was last synced with, as saved on this device. */
31
+ export function findCachedPortfolioAccount(
32
+ config: AppConfig,
33
+ portfolio: Portfolio,
34
+ resources: Pick<AppResourceStorePort, "list"> | null | undefined,
35
+ ): BrokerAccount | null {
36
+ if (!resources || !portfolio.brokerInstanceId) return null;
28
37
  return resolvePortfolioAccountState(
29
38
  portfolio,
30
- { config, brokerAccounts },
39
+ { config, brokerAccounts: peekCachedBrokerAccounts(config, resources) },
31
40
  { status: null, accounts: [] },
32
41
  )?.account ?? null;
33
42
  }
@@ -24,6 +24,7 @@ import { cliFreshnessFooter } from "../../../../cli/result";
24
24
  import { quotesFreshness } from "../../../../cli/freshness";
25
25
  import type { BrokerAccount } from "../../../../types/trading";
26
26
  import {
27
+ BROKER_ACCOUNT_MISSING_NOTE,
27
28
  CASH_SYMBOL,
28
29
  buildPortfolioAllocation,
29
30
  describeTargetSum,
@@ -507,6 +508,7 @@ async function showCollectionWithMarketData(
507
508
  notes.push(cliStyles.warning(`Total and weights exclude ${count} unpriced holding${count === 1 ? "" : "s"}: ${allocation.unpriced.join(", ")}.`));
508
509
  }
509
510
  if (cashExport?.source === "broker") notes.push(cliStyles.muted("Cash is the broker account's balance as of its last sync."));
511
+ if (matchedPortfolio.brokerInstanceId && !account) notes.push(cliStyles.warning(BROKER_ACCOUNT_MISSING_NOTE));
510
512
  if (cashExport && cashExport.value == null) {
511
513
  notes.push(cliStyles.warning(cashExport.currency
512
514
  ? `No ${cashExport.currency}/${currency} rate for the cash, so the total and weights are unavailable.`
@@ -16,6 +16,7 @@ import {
16
16
  type CollectionMatch,
17
17
  } from "./cli/render";
18
18
  import {
19
+ BROKER_ACCOUNT_MISSING_NOTE,
19
20
  CASH_SYMBOL,
20
21
  describeTargetSum,
21
22
  formatAllocationDrift,
@@ -210,6 +211,7 @@ async function portfolioHoldings(
210
211
  ? [`${rows.length - limit} more position${rows.length - limit === 1 ? "" : "s"} not shown; totals include every position.`]
211
212
  : []),
212
213
  ...(targetNote ? [targetNote] : []),
214
+ ...(target.portfolio.brokerInstanceId && !resolved.account ? [BROKER_ACCOUNT_MISSING_NOTE] : []),
213
215
  ];
214
216
 
215
217
  return {
@@ -172,7 +172,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
172
172
  const flashSymbols = useQuoteFlashMap(financialsMap, valueFlashingEnabled);
173
173
 
174
174
  const accountStateInput = useMemo(() => ({ brokerAccounts, config }), [brokerAccounts, config]);
175
- const { accountState, accountsError } = usePortfolioAccountState(currentPortfolio, accountStateInput);
175
+ const { accountState, accountsError, brokerError } = usePortfolioAccountState(currentPortfolio, accountStateInput);
176
+ const brokerPortfolio = isPortfolioTab && !!currentPortfolio?.brokerInstanceId;
176
177
  const portfolioTargets = currentPortfolio?.targetWeights;
177
178
  const columns = useMemo(
178
179
  () => resolveVisibleColumns(paneSettings.columnIds, isPortfolioTab, { targetWeights: hasTargetWeights(currentPortfolio) }),
@@ -338,7 +339,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
338
339
  currency: totalsCurrency,
339
340
  baseCurrency: config.baseCurrency,
340
341
  manualCash,
341
- }), [config.baseCurrency, convertAccountValue, isPortfolioTab, manualCash, portfolioSummaryTotals, summaryAccountState, totalsCurrency]);
342
+ brokerPortfolio,
343
+ }), [brokerPortfolio, config.baseCurrency, convertAccountValue, isPortfolioTab, manualCash, portfolioSummaryTotals, summaryAccountState, totalsCurrency]);
342
344
  // The header row sits in the pane's one-cell side padding, like the table.
343
345
  const summaryWidth = Math.max(0, width - 2);
344
346
  const summaryLayout = useMemo(() => layoutPortfolioSummaryHeader(summarySegments, summaryWidth, {
@@ -476,7 +478,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
476
478
  accountState: summaryAccountState,
477
479
  accountStatusText: accountsError
478
480
  ? `Accounts unavailable: ${accountsError}`
479
- : isPortfolioTab && currentPortfolio?.brokerInstanceId && !accountState ? "Acct missing" : undefined,
481
+ : brokerPortfolio ? brokerError ?? (accountState ? undefined : "Acct missing") : undefined,
480
482
  financialsMap,
481
483
  isPortfolioTab,
482
484
  refreshingSize,
@@ -485,7 +487,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
485
487
  }), [financialsMap, sortedTickers], [
486
488
  accountState,
487
489
  accountsError,
488
- currentPortfolio?.brokerInstanceId,
490
+ brokerError,
491
+ brokerPortfolio,
489
492
  isPortfolioTab,
490
493
  portfolioSummaryTotals,
491
494
  refreshingSize,
@@ -12,6 +12,7 @@ import { displayWidth, formatPercentRaw } from "../../../../utils/format";
12
12
  import { getBrokerInstance } from "../../../../utils/broker-instances";
13
13
  import {
14
14
  resolvePortfolioAccountMetrics,
15
+ resolvePortfolioLeverage,
15
16
  resolvePortfolioMarketValue,
16
17
  resolvePortfolioNetLiquidation,
17
18
  type BrokerSnapshotBasis,
@@ -223,10 +224,13 @@ export function buildPortfolioSummarySegments({
223
224
  currency = "USD",
224
225
  baseCurrency = "USD",
225
226
  manualCash = null,
227
+ brokerPortfolio = false,
226
228
  }: {
227
229
  totals: PortfolioSummaryTotals;
228
230
  accountState: PortfolioSummaryAccountState | null;
229
231
  isPortfolioTab?: boolean;
232
+ /** Synced from a broker, so it is worth its account's equity even while the account is missing. */
233
+ brokerPortfolio?: boolean;
230
234
  /** Into the totals currency. */
231
235
  convertAccountValue?: (value: number) => number;
232
236
  /** The totals currency. */
@@ -249,6 +253,9 @@ export function buildPortfolioSummarySegments({
249
253
 
250
254
  const candidates: PortfolioSummarySegment[] = [];
251
255
  const account = accountState?.account;
256
+ // A broker account is worth its equity. Its positions alone are its gross,
257
+ // which margin or shorts put above it, so that sum never reads as its value.
258
+ const broker = brokerPortfolio || !!account;
252
259
  const basis = accountState?.snapshotBasis;
253
260
  const accountMetrics = resolvePortfolioAccountMetrics(totals, account, convertAccountValue, basis);
254
261
  const totalMarketValue = resolvePortfolioMarketValue(totals, account, convertAccountValue, basis);
@@ -269,15 +276,17 @@ export function buildPortfolioSummarySegments({
269
276
  ]));
270
277
  }
271
278
 
272
- if (netLiquidation != null) {
279
+ // Unknown without the account, so it reads as missing rather than disappearing,
280
+ // unless cash entered by hand gives the Total above in its place.
281
+ if (broker && (netLiquidation != null || !manualCash)) {
273
282
  candidates.push(createSummarySegment("netliq", [
274
283
  { text: "Net Liq", tone: "label" },
275
- { text: money(netLiquidation), tone: "value", bold: true },
284
+ { text: money(netLiquidation ?? Number.NaN), tone: "value", bold: true },
276
285
  ]));
277
286
  }
278
287
 
279
288
  candidates.push(createSummarySegment("val", [
280
- { text: totals.hasShorts ? "Gross" : "Val", tone: "label" },
289
+ { text: broker || totals.hasShorts ? "Gross" : "Val", tone: "label" },
281
290
  { text: money(totalMarketValue), tone: "value", bold: true },
282
291
  ]));
283
292
 
@@ -289,7 +298,7 @@ export function buildPortfolioSummarySegments({
289
298
  }
290
299
 
291
300
  // A broker account always states its cash, so a missing balance reads as unknown rather than zero.
292
- if (account) candidates.push(accountValue("cash", "Cash", account.totalCashValue ?? Number.NaN)!);
301
+ if (broker && !manualCash) candidates.push(accountValue("cash", "Cash", account?.totalCashValue ?? Number.NaN)!);
293
302
  if (manualCash) {
294
303
  const drift = formatAllocationDrift(manualCash.drift);
295
304
  candidates.push(createSummarySegment("cash", [
@@ -318,6 +327,14 @@ export function buildPortfolioSummarySegments({
318
327
 
319
328
  if (!account) return candidates;
320
329
 
330
+ const leverage = resolvePortfolioLeverage(totalMarketValue, netLiquidation);
331
+ // Only while the account borrows or is short enough to read above 1.0x, as the analytics pane rounds it.
332
+ const lev = leverage != null && leverage >= 1.05
333
+ ? createSummarySegment("lev", [
334
+ { text: "Lev", tone: "label" },
335
+ { text: `${leverage.toFixed(1)}x`, tone: "value", bold: true },
336
+ ])
337
+ : null;
321
338
  const realized = accountMetrics.realizedPnl != null
322
339
  ? createSummarySegment("realized", [
323
340
  { text: "Realized", tone: "label" },
@@ -327,6 +344,7 @@ export function buildPortfolioSummarySegments({
327
344
  return [
328
345
  ...candidates,
329
346
  ...[
347
+ lev,
330
348
  realized,
331
349
  accountValue("settled", "Settled", account.settledCash),
332
350
  accountValue("avail", "Avail", account.availableFunds),
@@ -84,6 +84,12 @@ export interface PortfolioAccountStateResult {
84
84
  accountState: ResolvedPortfolioAccountState | null;
85
85
  /** Set when the broker refused to list accounts, so "no cash" is not mistaken for a clean empty. */
86
86
  accountsError: string | null;
87
+ /**
88
+ * The broker's last call failed, such as the sync at startup after its
89
+ * sign-in lapsed. Accounts are listed only while it is connected, so this
90
+ * is the only word of why the account is missing or stale.
91
+ */
92
+ brokerError: string | null;
87
93
  }
88
94
 
89
95
  /**
@@ -111,5 +117,9 @@ export function usePortfolioAccountState(
111
117
  () => resolvePortfolioAccountState(portfolio, state, snapshot),
112
118
  [portfolio, snapshot, state.brokerAccounts, state.config],
113
119
  );
114
- return useMemo(() => ({ accountState, accountsError: live.error }), [accountState, live.error]);
120
+ const brokerError = live.status?.state === "error" ? live.status.message?.trim() || "Broker unavailable" : null;
121
+ return useMemo(
122
+ () => ({ accountState, accountsError: live.error, brokerError }),
123
+ [accountState, brokerError, live.error],
124
+ );
115
125
  }
@@ -1,5 +1,6 @@
1
1
  import { comparablePriceEarnings, notMeaningfulMultiples } from "../../../utils/price-earnings";
2
2
  import { selectMarketCapitalization } from "../../../utils/market-capitalization";
3
+ import { reportedEnterpriseValue } from "../../../utils/fundamentals";
3
4
  import type { Quote, TickerFinancials } from "../../../types/financials";
4
5
 
5
6
  export const RELATIVE_VALUATION_STALE_QUOTE_NOTICE = "Quote stale: quote-based values unavailable";
@@ -10,6 +11,9 @@ export function relativeValuationValues(financials: TickerFinancials | null) {
10
11
  const fundamentals = financials?.fundamentals;
11
12
  const quoteStale = quote?.stale === true;
12
13
  const capitalization = selectMarketCapitalization(quoteStale ? undefined : quote, fundamentals);
14
+ const enterpriseValue = reportedEnterpriseValue(fundamentals);
15
+ // A zero or non-finite enterprise value is the source's gap, and the ratio it serves beside it comes from the same observation.
16
+ const enterpriseValueGap = fundamentals?.enterpriseValue != null && enterpriseValue == null;
13
17
  const compatibleCurrency = !!fundamentals?.financialCurrency && !!quote?.currency
14
18
  && fundamentals.financialCurrency === quote.currency;
15
19
  const reportedMultiples = {
@@ -47,11 +51,11 @@ export function relativeValuationValues(financials: TickerFinancials | null) {
47
51
  /** Why a multiple is N/M: its earnings base is zero or a loss. */
48
52
  notMeaningful,
49
53
  // Vendor ADR ratios can mix unverified valuation and reporting units too.
50
- evSales: !compatibleCurrency ? null
54
+ evSales: !compatibleCurrency || enterpriseValueGap ? null
51
55
  : fundamentals?.enterpriseToRevenue != null && Number.isFinite(fundamentals.enterpriseToRevenue)
52
56
  ? fundamentals.enterpriseToRevenue
53
- : fundamentals?.enterpriseValue != null && fundamentals.revenue != null && fundamentals.revenue > 0
54
- ? fundamentals.enterpriseValue / fundamentals.revenue : null,
57
+ : enterpriseValue != null && fundamentals?.revenue != null && fundamentals.revenue > 0
58
+ ? enterpriseValue / fundamentals.revenue : null,
55
59
  fcfYield: capitalization && fundamentals?.financialCurrency === capitalization.currency
56
60
  && fundamentals.freeCashFlow != null && Number.isFinite(fundamentals.freeCashFlow) && capitalization.value > 0
57
61
  ? fundamentals.freeCashFlow / capitalization.value : null,
@@ -1,4 +1,5 @@
1
1
  import type { TickerFinancials } from "../../../types/financials";
2
+ import { reportedEnterpriseValue } from "../../../utils/fundamentals";
2
3
 
3
4
  /** Years of explicit growth before the terminal value. */
4
5
  export const FORECAST_YEARS = 10;
@@ -65,7 +66,7 @@ function pastFcfGrowth(financials: TickerFinancials): ReverseDcfModel["pastGrowt
65
66
  export function projectReverseDcf(financials: TickerFinancials | null, options: { symbol: string; discountRate: number }): ReverseDcfModel {
66
67
  const fundamentals = financials?.fundamentals;
67
68
  const currency = fundamentals?.financialCurrency ?? financials?.financialCurrency ?? null;
68
- const enterpriseValue = fundamentals?.enterpriseValue ?? null;
69
+ const enterpriseValue = reportedEnterpriseValue(fundamentals) ?? null;
69
70
  const freeCashFlow = fundamentals?.freeCashFlow ?? null;
70
71
  const empty: ReverseDcfModel = { symbol: options.symbol, currency, enterpriseValue, freeCashFlow, fcfYield: null, implied: null,
71
72
  pastGrowth: financials ? pastFcfGrowth(financials) : null, sensitivity: [], error: null };