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.
- package/package.json +1 -1
- package/src/brokers/account-cache.ts +45 -0
- package/src/cli/command-options.ts +74 -0
- package/src/cli/commands/command-utils.ts +29 -0
- package/src/cli/commands/market.ts +170 -54
- package/src/cli/commands/overview.ts +62 -19
- package/src/cli/commands/ticker.ts +138 -43
- package/src/cli/desktop-pane-shot.ts +3 -0
- package/src/cli/help.ts +26 -6
- package/src/cli/history-rows.ts +187 -15
- package/src/cli/index.ts +12 -7
- package/src/cli/options.ts +5 -1
- package/src/cli/pane-functions/capabilities.ts +5 -0
- package/src/cli/pane-functions/dom.ts +69 -16
- package/src/cli/pane-functions/function-help.ts +4 -2
- package/src/cli/pane-functions/headless.ts +163 -31
- package/src/cli/pane-functions/index.ts +9 -2
- package/src/cli/pane-functions/report.ts +8 -1
- package/src/cli/pane-functions/resolver.ts +28 -2
- package/src/cli/pane-functions/screenshot.ts +4 -0
- package/src/cli/registry.ts +6 -3
- package/src/cli/report-tables.ts +460 -0
- package/src/cli/result.ts +13 -3
- package/src/components/data-table/export.ts +6 -51
- package/src/components/data-table/view.tsx +3 -1
- package/src/components/layout/shell/active-drag.ts +67 -35
- package/src/components/layout/shell/cursor-occlusion.ts +6 -5
- package/src/components/layout/shell/drag/index.ts +12 -7
- package/src/components/layout/shell/drag/live.ts +122 -0
- package/src/components/layout/shell/drag/overlays.tsx +179 -35
- package/src/components/layout/shell/drag/runtime.ts +62 -50
- package/src/components/layout/shell/drag/slide.ts +10 -0
- package/src/components/layout/shell/index.tsx +37 -51
- package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
- package/src/components/layout/shell/native/surfaces.ts +30 -14
- package/src/components/layout/shell/pane/layers.tsx +419 -179
- package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
- package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
- package/src/data/json-persistence.ts +17 -3
- package/src/data/json-storage.ts +2 -0
- package/src/data/memory-resource-store.ts +46 -0
- package/src/market-data/market/freshness.ts +45 -0
- package/src/market-data/quotes/freshness.ts +5 -0
- package/src/plugins/builtin/analytics/pane-model.ts +10 -6
- package/src/plugins/builtin/attention/pane.tsx +1 -1
- package/src/plugins/builtin/catalysts/detail.tsx +1 -1
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/holders/client.ts +3 -6
- package/src/plugins/builtin/holders/format.ts +31 -1
- package/src/plugins/builtin/holders/headless.ts +26 -5
- package/src/plugins/builtin/holders/pane.tsx +22 -13
- package/src/plugins/builtin/holders/report-header.ts +46 -5
- package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
- package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
- package/src/plugins/builtin/members/pane.tsx +10 -0
- package/src/plugins/builtin/perps/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
- package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
- package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
- package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
- package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
- package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
- package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
- package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
- package/src/plugins/builtin/shared/members-menu.tsx +260 -0
- package/src/plugins/builtin/short-volume/pane.tsx +1 -1
- package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
- package/src/plugins/builtin/themes/pane.tsx +3 -0
- package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
- package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
- package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
- package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
- package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
- package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
- package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
- package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
- package/src/plugins/builtin/world-venue-map/index.ts +5 -5
- package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
- package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
- package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
- package/src/plugins/builtin/world-venue-map/model.ts +16 -0
- package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
- package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
- package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
- package/src/renderers/browser/app-services.ts +3 -1
- package/src/renderers/dom/data-table/dom.ts +31 -0
- package/src/renderers/dom/data-table/index.tsx +5 -0
- package/src/renderers/dom/host/box.tsx +113 -17
- package/src/renderers/dom/host/drag-shield.ts +24 -0
- package/src/renderers/dom/host/live-frame.ts +54 -0
- package/src/renderers/dom/host/mouse.ts +8 -1
- package/src/renderers/dom/host/scroll-box.tsx +6 -5
- package/src/renderers/dom/host/tabs.tsx +3 -2
- package/src/renderers/dom/styles.css +13 -5
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
- package/src/renderers/electrobun/shared/protocol.ts +10 -1
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
- package/src/renderers/electrobun/view/host/native.ts +0 -10
- package/src/renderers/electrobun/view/host/window-move.ts +28 -0
- package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
- package/src/renderers/electrobun/view/ui-host.tsx +2 -5
- package/src/renderers/opentui/ui-host.tsx +7 -1
- package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
- package/src/sources/provider-router/batches.ts +3 -3
- package/src/sources/provider-router/financial-routes.ts +2 -2
- package/src/sources/provider-router/financials.ts +70 -7
- package/src/sources/provider-router/primary.ts +2 -2
- package/src/state/pane-scroll-registry.tsx +67 -35
- package/src/test-support/margin-account.ts +108 -0
- package/src/time-series/resolution.ts +15 -0
- package/src/types/financials.ts +32 -3
- package/src/ui/host.tsx +12 -0
- package/src/ui/index.tsx +8 -3
- package/src/ui/scroll-box-axis.ts +26 -0
- package/src/utils/currency-units.ts +8 -3
- package/src/utils/depositary-receipt.ts +54 -10
- package/src/utils/display-number.ts +63 -0
- package/src/utils/fundamentals.ts +17 -1
- 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"
|
|
50
|
-
const
|
|
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
|
-
|
|
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
|
|
76
|
-
// stakes follow it at most every few
|
|
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
|
|
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
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
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:
|
|
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 -
|
|
235
|
+
{clipToDisplayWidth(row.name, Math.max(1, column.width - badges * (BADGE_WIDTH + 1)))}
|
|
228
236
|
</Text>
|
|
229
|
-
<Badge label=
|
|
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
|
-
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
241
|
-
|
|
242
|
-
:
|
|
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
|
-
:
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the same
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
:
|
|
54
|
-
?
|
|
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
|
|
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 };
|