@gloom-sh/terminal 0.19.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/patches/electrobun@1.18.1.patch +23 -1
- package/src/api-client/app-attention.ts +3 -0
- package/src/api-client/beneficial-owners.ts +3 -0
- package/src/api-client/company-kpis.ts +3 -0
- package/src/api-client/data.ts +2 -1
- package/src/api-client/hiring.ts +3 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/ipo.ts +24 -1
- package/src/api-client/mna.ts +3 -0
- package/src/api-client/paths.ts +9 -1
- package/src/api-client/roadmap.ts +85 -0
- package/src/api-client/types.ts +12 -0
- package/src/architecture/core-builtin-allowlist.json +2 -2
- package/src/cli/commands/market.ts +35 -12
- package/src/cli/commands/news-search.ts +22 -0
- package/src/cli/commands/overview.ts +86 -20
- package/src/cli/commands/ticker.ts +32 -2
- package/src/cli/errors.ts +17 -2
- package/src/cli/glossary.ts +6 -0
- package/src/cli/index-roots.ts +0 -7
- package/src/cli/listing-arg.ts +57 -10
- package/src/cli/pane-functions/capabilities.ts +1 -0
- package/src/cli/pane-functions/discovery.ts +21 -1
- package/src/cli/pane-functions/freshness.ts +7 -1
- package/src/cli/pane-functions/function-help.ts +7 -0
- package/src/cli/pane-functions/index.ts +6 -3
- package/src/cli/quote-answer.ts +1 -1
- package/src/cli/saved-copy-persistence.ts +88 -0
- package/src/components/command-bar/list/model.ts +5 -0
- package/src/components/command-bar/routes/root/runtime.ts +6 -7
- package/src/components/command-bar/selection-runtime.ts +52 -3
- package/src/components/command-bar/surface/index.tsx +2 -0
- package/src/components/layout/pane/capture-stamp.ts +2 -2
- package/src/components/layout/pane/footer/index.tsx +22 -6
- package/src/components/layout/pane/footer/model.ts +83 -15
- package/src/components/layout/pane/status-footer.ts +35 -2
- package/src/components/layout/status-widget-space.ts +9 -0
- package/src/components/ticker/list-table-view.tsx +4 -20
- package/src/components/ui/display.tsx +18 -3
- package/src/components/ui/icon.tsx +4 -1
- package/src/data/plugin-cache.ts +5 -3
- package/src/data/saved-copies.ts +8 -0
- package/src/docs/snapshot.json +32 -5
- package/src/i18n/es.ts +2 -4
- package/src/i18n/ja.ts +2 -4
- package/src/i18n/ko.ts +2 -4
- package/src/i18n/zh-cn.ts +2 -4
- package/src/i18n/zh-tw.ts +2 -4
- package/src/market-data/depositary-pair.ts +25 -1
- package/src/market-data/quotes/metadata.ts +7 -0
- package/src/news/aggregator.ts +7 -1
- package/src/news/types.ts +6 -0
- package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
- package/src/plugins/builtin/alerts/cli.ts +34 -11
- package/src/plugins/builtin/alerts/event-rows.ts +24 -3
- package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
- package/src/plugins/builtin/alerts/headless.ts +10 -6
- package/src/plugins/builtin/cds/client.ts +142 -30
- package/src/plugins/builtin/cds/headless.ts +35 -8
- package/src/plugins/builtin/cds/model.ts +145 -18
- package/src/plugins/builtin/cds/pane.tsx +16 -5
- package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
- package/src/plugins/builtin/chart-composer/index.tsx +4 -5
- package/src/plugins/builtin/chat/controller/index.ts +5 -1
- package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
- package/src/plugins/builtin/chat/controller/state.ts +25 -2
- package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
- package/src/plugins/builtin/chat/controller/utils.ts +7 -2
- package/src/plugins/builtin/chat/status-preview.ts +79 -0
- package/src/plugins/builtin/chat/status-widget.tsx +147 -10
- package/src/plugins/builtin/chat/unread-badge.ts +51 -10
- package/src/plugins/builtin/clinical-trials/client.ts +49 -4
- package/src/plugins/builtin/clinical-trials/model.ts +86 -22
- package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
- package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
- package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
- package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
- package/src/plugins/builtin/company-attention/headless.ts +2 -1
- package/src/plugins/builtin/company-attention/pane.tsx +3 -0
- package/src/plugins/builtin/company-kpis/headless.ts +8 -3
- package/src/plugins/builtin/company-kpis/model.ts +73 -5
- package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
- package/src/plugins/builtin/composite-plugins.ts +2 -1
- package/src/plugins/builtin/credit-documents/headless.ts +3 -3
- package/src/plugins/builtin/doe/headless.ts +3 -2
- package/src/plugins/builtin/doe/pane.tsx +13 -3
- package/src/plugins/builtin/earnings-calls/data.ts +18 -6
- package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
- package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
- package/src/plugins/builtin/econ/calendar-model.ts +48 -1
- package/src/plugins/builtin/econ/calendar-source.ts +4 -2
- package/src/plugins/builtin/econ/detail-view.tsx +2 -2
- package/src/plugins/builtin/econ/figures.ts +48 -0
- package/src/plugins/builtin/econ/index.tsx +14 -5
- package/src/plugins/builtin/econ/print-watch.ts +3 -1
- package/src/plugins/builtin/econ-statistics/client.ts +11 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
- package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
- package/src/plugins/builtin/help/index.tsx +61 -59
- package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
- package/src/plugins/builtin/help/table-cells.tsx +13 -2
- package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
- package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
- package/src/plugins/builtin/holders/headless.ts +4 -2
- package/src/plugins/builtin/holders/pane.tsx +3 -0
- package/src/plugins/builtin/holders/report-rows.ts +4 -1
- package/src/plugins/builtin/insider/headless.ts +2 -2
- package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
- package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
- package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
- package/src/plugins/builtin/macro-day/headless.ts +12 -5
- package/src/plugins/builtin/macro-day/index.tsx +1 -1
- package/src/plugins/builtin/macro-day/model.ts +39 -5
- package/src/plugins/builtin/macro-day/pane.tsx +27 -19
- package/src/plugins/builtin/market-movers/client.ts +2 -0
- package/src/plugins/builtin/market-movers/headless.ts +3 -1
- package/src/plugins/builtin/market-movers/screener.ts +8 -4
- package/src/plugins/builtin/mna/headless.ts +2 -0
- package/src/plugins/builtin/mna/pane.tsx +3 -1
- package/src/plugins/builtin/news/headless.ts +3 -1
- package/src/plugins/builtin/news/index.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
- package/src/plugins/builtin/realized-vol/client.ts +13 -5
- package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
- package/src/plugins/builtin/realized-vol/headless.ts +8 -4
- package/src/plugins/builtin/realized-vol/model.ts +37 -6
- package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
- package/src/plugins/builtin/research/event-model.ts +30 -4
- package/src/plugins/builtin/research/index.tsx +7 -3
- package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
- package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
- package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
- package/src/plugins/builtin/risk-factors/data.ts +4 -2
- package/src/plugins/builtin/risk-factors/headless.ts +5 -2
- package/src/plugins/builtin/roadmap/index.tsx +28 -0
- package/src/plugins/builtin/roadmap/model.ts +67 -0
- package/src/plugins/builtin/roadmap/pane.tsx +244 -0
- package/src/plugins/builtin/scanner/live-headless.ts +21 -2
- package/src/plugins/builtin/sec/headless.ts +2 -1
- package/src/plugins/builtin/sec/index.tsx +3 -0
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
- package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
- package/src/plugins/builtin/shared/report-freshness.ts +8 -0
- package/src/plugins/builtin/shared/research-cells.tsx +1 -1
- package/src/plugins/builtin/shared/series-cache.ts +4 -4
- package/src/plugins/builtin/thirteenf/api.ts +12 -1
- package/src/plugins/builtin/thirteenf/data.ts +7 -7
- package/src/plugins/builtin/thirteenf/headless.ts +31 -4
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
- package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/pane.tsx +5 -2
- package/src/plugins/builtin/yield-curve/client.ts +61 -1
- package/src/plugins/builtin/yield-curve/headless.ts +9 -6
- package/src/plugins/builtin/yield-curve/index.tsx +34 -10
- package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
- package/src/plugins/catalog-browser.ts +2 -1
- package/src/plugins/host-link.ts +1 -1
- package/src/portfolio/column-values.ts +11 -7
- package/src/renderers/dom/desktop/icons.tsx +7 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
- package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
- package/src/renderers/electrobun/bun/index.ts +26 -2
- package/src/renderers/electrobun/bun/window/create.ts +43 -4
- package/src/renderers/electrobun/bun/window/events.ts +20 -0
- package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
- package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
- package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
- package/src/renderers/electrobun/shared/protocol.ts +2 -0
- package/src/renderers/electrobun/view/news-source.ts +2 -0
- package/src/renderers/electrobun/view/ui-host.tsx +8 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
- package/src/sources/gloomberb-cloud/index.ts +42 -8
- package/src/tickers/company-name-listing.ts +53 -13
- package/src/tickers/company-name.ts +6 -1
- package/src/tickers/index-roots.ts +7 -0
- package/src/tickers/listing-fate.ts +2 -2
- package/src/tickers/search/index.ts +6 -2
- package/src/tickers/search/ranking.ts +5 -1
- package/src/tickers/search/upsert.ts +13 -0
- package/src/tickers/ticker-changes.ts +8 -4
- package/src/types/company-from.ts +40 -0
- package/src/types/data-provider.ts +7 -0
- package/src/types/financials.ts +27 -0
- package/src/types/headless.ts +6 -0
- package/src/utils/crypto-pair.ts +121 -7
- package/src/utils/declared-dividends.ts +1 -1
- package/src/utils/fundamentals.ts +36 -5
- package/src/version.ts +2 -2
|
@@ -6,6 +6,8 @@ import { CorporateActionsView } from "./corporate-actions-pane";
|
|
|
6
6
|
import { EquityDiagnosticView } from "./equity-diagnostic-pane";
|
|
7
7
|
import { RelativeValuationPane } from "./relative-valuation-pane";
|
|
8
8
|
import { relativeValuationHeadless } from "./relative-valuation-headless";
|
|
9
|
+
import { AUTO_PEERS_SETTING, relativeValuationTitle } from "./relative-valuation-peers";
|
|
10
|
+
import { buildRelativeValuationSettingsDef, MAX_RELATIVE_VALUATION_TICKERS } from "./relative-valuation-settings";
|
|
9
11
|
import { analystResearchHeadless } from "./analyst-headless";
|
|
10
12
|
import { eventsHeadless } from "./events-headless";
|
|
11
13
|
import { earningsEstimatesHeadless } from "./headless";
|
|
@@ -95,6 +97,7 @@ export const researchModule: PluginModule = {
|
|
|
95
97
|
defaultMode: "floating",
|
|
96
98
|
defaultFloatingSize: { width: 104, height: 24 },
|
|
97
99
|
tableExport: true,
|
|
100
|
+
settings: buildRelativeValuationSettingsDef,
|
|
98
101
|
},
|
|
99
102
|
{
|
|
100
103
|
id: "earnings-estimates",
|
|
@@ -168,14 +171,15 @@ export const researchModule: PluginModule = {
|
|
|
168
171
|
try {
|
|
169
172
|
symbols = options?.symbols?.length
|
|
170
173
|
? options.symbols
|
|
171
|
-
: parseTickerListInput(options?.arg ?? context.activeTicker ?? "",
|
|
174
|
+
: parseTickerListInput(options?.arg ?? context.activeTicker ?? "", MAX_RELATIVE_VALUATION_TICKERS);
|
|
172
175
|
} catch {
|
|
173
176
|
return null;
|
|
174
177
|
}
|
|
175
178
|
return {
|
|
176
|
-
title:
|
|
179
|
+
title: relativeValuationTitle(symbols, null),
|
|
177
180
|
placement: "floating",
|
|
178
|
-
|
|
181
|
+
// One ticker is compared with the peers `--peers` picks, which the pane finds once it opens.
|
|
182
|
+
settings: { symbols, symbolsText: formatTickerListInput(symbols), ...(symbols.length === 1 ? { [AUTO_PEERS_SETTING]: true } : {}) },
|
|
179
183
|
};
|
|
180
184
|
},
|
|
181
185
|
},
|
|
@@ -8,11 +8,10 @@ import {
|
|
|
8
8
|
RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE, RELATIVE_VALUATION_STALE_QUOTE_NOTICE, relativeValuationValues, type RelativeValuationGapKey,
|
|
9
9
|
} from "./relative-valuation-model";
|
|
10
10
|
import { paneSchemas } from "./headless-schema";
|
|
11
|
-
import { isRecord } from "../../../utils/guards";
|
|
12
|
-
import { errorMessage } from "../../../utils/errors";
|
|
13
|
-
import { notATickerSymbol } from "../../../sources/provider-errors";
|
|
14
11
|
import { resolveCurrencyUnit } from "../../../utils/currency-units";
|
|
15
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
AUTO_PEERS_SETTING, findPeers, peerMethodTitle, peerSelectionOf, PEER_SELECTION_SETTING, relativeValuationTitle, type PeerSelection,
|
|
14
|
+
} from "./relative-valuation-peers";
|
|
16
15
|
import { extraColumns, extraColumnsBasis, loadExtraColumns, parseExtraColumns } from "./relative-valuation-columns";
|
|
17
16
|
import { fundamentalsBasisParts } from "./relative-valuation-basis";
|
|
18
17
|
import { conversionNote, convertMarketCaps, loadFxLegs } from "./relative-valuation-currency";
|
|
@@ -103,33 +102,14 @@ const VALUATION_COLUMNS: HeadlessPaneColumn[] = [
|
|
|
103
102
|
})),
|
|
104
103
|
];
|
|
105
104
|
|
|
106
|
-
/** Peers `--peers` chose, as the expansion left them in the settings or the load picked them. */
|
|
107
|
-
function peerSelectionOf(value: unknown, symbols: readonly string[]): PeerSelection | null {
|
|
108
|
-
if (!isRecord(value) || typeof value.subject !== "string" || !Array.isArray(value.peers)) return null;
|
|
109
|
-
const selection = value as unknown as PeerSelection;
|
|
110
|
-
const expected = [selection.subject, ...selection.peers];
|
|
111
|
-
return expected.length === symbols.length && expected.every((symbol, index) => symbol.toUpperCase() === symbols[index]?.toUpperCase())
|
|
112
|
-
? selection : null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
105
|
/** The peers of the one symbol `--peers` was given; several symbols are already a peer list. */
|
|
116
106
|
async function choosePeers(symbols: readonly string[], ctx: HeadlessPaneContext): Promise<PeerSelection> {
|
|
117
107
|
if (symbols.length !== 1) {
|
|
118
108
|
throw new Error(`--peers picks the peers of one symbol, such as RV ${symbols[0] ?? "DUOL"} --peers; with several symbols, leave it out.`);
|
|
119
109
|
}
|
|
120
|
-
|
|
121
|
-
const financials = await loadHeadlessFinancials(ctx, subject).catch((error: unknown) => {
|
|
122
|
-
ctx.signal.throwIfAborted();
|
|
123
|
-
// A word that is no ticker is said as `fn RV duolingo lrn` says it, which the CLI follows with the search to run.
|
|
124
|
-
if (notATickerSymbol(errorMessage(error))) throw error;
|
|
125
|
-
// Any other failure leaves the peers to what the symbol alone places (a curated core's member).
|
|
126
|
-
return null;
|
|
127
|
-
});
|
|
128
|
-
return pickPeers(subject, financials, ctx);
|
|
110
|
+
return findPeers(symbols[0]!, ctx);
|
|
129
111
|
}
|
|
130
112
|
|
|
131
|
-
const PEER_SELECTION_SETTING = "peerSelection";
|
|
132
|
-
|
|
133
113
|
export const relativeValuationHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
134
114
|
...paneSchemas["relative-valuation-pane"],
|
|
135
115
|
// What Ask Gloom reads. The template's "across peers" never said this is where a list of holdings' P/E is.
|
|
@@ -149,9 +129,12 @@ export const relativeValuationHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
|
149
129
|
return {
|
|
150
130
|
argument: [selection.subject, ...selection.peers].join(", "),
|
|
151
131
|
settings: { [PEER_SELECTION_SETTING]: selection },
|
|
152
|
-
title:
|
|
132
|
+
title: relativeValuationTitle([], selection),
|
|
153
133
|
};
|
|
154
134
|
},
|
|
135
|
+
// The app adds the peers of a pane opened on one ticker; a capture draws the tickers given, as the report does,
|
|
136
|
+
// unless --peers widened them.
|
|
137
|
+
shotState: (args) => args.options.peers === true ? {} : { settings: { [AUTO_PEERS_SETTING]: false } },
|
|
155
138
|
async load(args, ctx) {
|
|
156
139
|
// --peers picked in advance (fn and shot) reads the same peers; any other caller picks them here.
|
|
157
140
|
const preset = args.options.peers === true ? peerSelectionOf(ctx.settings?.[PEER_SELECTION_SETTING], args.symbols) : null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatPriceEarnings, NOT_MEANINGFUL } from "../../../utils/price-earnings";
|
|
2
|
-
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { TextAttributes } from "../../../ui";
|
|
4
4
|
import {
|
|
5
5
|
DataTableView,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type PaneFooterSegment,
|
|
12
12
|
} from "../../../components";
|
|
13
13
|
import type { PaneProps } from "../../../types/plugin";
|
|
14
|
-
import { useAppSelector, usePaneInstance } from "../../../state/app/context";
|
|
14
|
+
import { useAppSelector, usePaneInstance, usePaneTitle, useUpdatePaneSettings } from "../../../state/app/context";
|
|
15
15
|
import { getSharedMarketDataCoordinator, resolveEntryValue } from "../../../market-data/coordinator";
|
|
16
16
|
import { buildQuoteKey } from "../../../market-data/selectors";
|
|
17
17
|
import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
|
|
@@ -25,7 +25,10 @@ import { compareSortValues, nextHeaderSort, type SortDirection } from "../../../
|
|
|
25
25
|
import { formatCompact, formatLevelPercent, formatNumber, formatPercent, formatPercentRaw } from "../../../utils/format";
|
|
26
26
|
import { parseDisplayDate } from "../../../utils/datetime-format";
|
|
27
27
|
import { convertMarketCapitalization } from "../../../utils/market-capitalization";
|
|
28
|
-
import { usePluginTickerActions } from "../../runtime";
|
|
28
|
+
import { useAssetData, usePluginTickerActions } from "../../runtime";
|
|
29
|
+
import { apiClient } from "../../../api-client";
|
|
30
|
+
import { errorMessage } from "../../../utils/errors";
|
|
31
|
+
import { formatTickerListInput } from "../../../tickers/list";
|
|
29
32
|
import { handleRefreshKey, useClampSelectedIndex } from "../../../components/data-table/table-pane";
|
|
30
33
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
31
34
|
import { useBoundTicker as useSymbolBinding } from "../shared/ticker-request";
|
|
@@ -35,6 +38,10 @@ import { quoteFreshnessFields } from "../shared/report-freshness";
|
|
|
35
38
|
import { RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE, formatRelativePrice, relativeValuationValues, withLiveQuote } from "./relative-valuation-model";
|
|
36
39
|
import { conversionNote, conversionPairText, convertMarketCaps, usdRateFromEntry } from "./relative-valuation-currency";
|
|
37
40
|
import { fundamentalsBasisParts, fundamentalsThroughText } from "./relative-valuation-basis";
|
|
41
|
+
import {
|
|
42
|
+
AUTO_PEERS_SETTING, findPeers, PEER_SELECTION_SETTING, PeerPickError, peerSelectionOf, relativeValuationTitle,
|
|
43
|
+
} from "./relative-valuation-peers";
|
|
44
|
+
import { relativeSymbolsFromPane } from "./relative-valuation-settings";
|
|
38
45
|
|
|
39
46
|
type ExtendedColumnId = "preMarket" | "afterHours" | "overnight";
|
|
40
47
|
type RelativeColumnId = "symbol" | "price" | "changePercent" | ExtendedColumnId | "marketCap" | "trailingPE" | "forwardPE" | "evSales" | "fcfYield" | "revenueGrowth" | "operatingMargin";
|
|
@@ -44,14 +51,6 @@ type RelativeRow = ReturnType<typeof relativeValuationValues> & {
|
|
|
44
51
|
error?: string;
|
|
45
52
|
};
|
|
46
53
|
|
|
47
|
-
function relativeSymbolsFromPane(symbol: string | null, paneSettings: Record<string, unknown> | undefined): string[] {
|
|
48
|
-
const settingsSymbols = Array.isArray(paneSettings?.symbols)
|
|
49
|
-
? paneSettings.symbols.filter((value): value is string => typeof value === "string")
|
|
50
|
-
: [];
|
|
51
|
-
if (settingsSymbols.length > 0) return settingsSymbols;
|
|
52
|
-
return symbol ? [symbol] : [];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
54
|
const EXTENDED_COLUMN_IDS: Record<ExtendedSession, ExtendedColumnId> = { PRE: "preMarket", POST: "afterHours", OVERNIGHT: "overnight" };
|
|
56
55
|
|
|
57
56
|
/** The latest quarter's revenue against the same quarter a year earlier; the footer names the quarter. */
|
|
@@ -151,6 +150,8 @@ function sortRelativeRowIndices(
|
|
|
151
150
|
.map((entry) => entry.index);
|
|
152
151
|
}
|
|
153
152
|
|
|
153
|
+
const NO_SYMBOLS: string[] = [];
|
|
154
|
+
|
|
154
155
|
/** Live values keep moving; the row order follows them at most this often. */
|
|
155
156
|
const RELATIVE_ORDER_SAMPLE_MS = 5_000;
|
|
156
157
|
|
|
@@ -170,7 +171,37 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
170
171
|
// Keyed on the list itself, so a settings write that leaves the peers alone
|
|
171
172
|
// does not start the snapshot over.
|
|
172
173
|
const symbolList = relativeSymbolsFromPane(symbol, pane?.settings).join(",");
|
|
173
|
-
const
|
|
174
|
+
const listed = useMemo(() => (symbolList ? symbolList.split(",") : []), [symbolList]);
|
|
175
|
+
const settings = pane?.settings;
|
|
176
|
+
// A pane opened on one ticker compares it with the peers `fn RV <ticker> --peers` picks, by the same
|
|
177
|
+
// method, and keeps them in its settings with how they were chosen.
|
|
178
|
+
const peerSelection = useMemo(() => peerSelectionOf(settings?.[PEER_SELECTION_SETTING], listed), [listed, settings]);
|
|
179
|
+
const peerSubject = settings?.[AUTO_PEERS_SETTING] === true && listed.length === 1 && !peerSelection ? listed[0]! : null;
|
|
180
|
+
const dataProvider = useAssetData();
|
|
181
|
+
const updateSettings = useUpdatePaneSettings();
|
|
182
|
+
const [peerAttempt, setPeerAttempt] = useState(0);
|
|
183
|
+
const [peerFailure, setPeerFailure] = useState<{ subject: string; message: string } | null>(null);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!peerSubject || !dataProvider) return;
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
setPeerFailure(null);
|
|
188
|
+
findPeers(peerSubject, { marketData: dataProvider, apiClient, signal: controller.signal }).then((selection) => {
|
|
189
|
+
if (controller.signal.aborted) return;
|
|
190
|
+
const compared = [selection.subject, ...selection.peers];
|
|
191
|
+
updateSettings({ symbols: compared, symbolsText: formatTickerListInput(compared), [PEER_SELECTION_SETTING]: selection });
|
|
192
|
+
}, (failure: unknown) => {
|
|
193
|
+
if (controller.signal.aborted) return;
|
|
194
|
+
setPeerFailure({ subject: peerSubject, message: failure instanceof PeerPickError ? failure.reason : `No peers: ${errorMessage(failure)}` });
|
|
195
|
+
});
|
|
196
|
+
return () => controller.abort();
|
|
197
|
+
}, [dataProvider, peerAttempt, peerSubject, updateSettings]);
|
|
198
|
+
const peerError = peerSubject && peerFailure?.subject === peerSubject ? peerFailure.message : null;
|
|
199
|
+
// The table waits for the peers, so it fills once; without any it shows the ticker alone.
|
|
200
|
+
const pickingPeers = peerSubject != null && peerError == null;
|
|
201
|
+
const symbols = pickingPeers ? NO_SYMBOLS : listed;
|
|
202
|
+
// The title says how the peers were chosen, or lists the tickers; a title the user gave the pane stays.
|
|
203
|
+
const ownsTitle = /^RV(?:\s|$)/.test(pane?.title ?? "");
|
|
204
|
+
usePaneTitle(ownsTitle && listed.length > 0 ? relativeValuationTitle(listed, peerSelection) : null);
|
|
174
205
|
const { navigateTicker } = usePluginTickerActions();
|
|
175
206
|
const loadPeers = useCallback(async (forceRefresh: boolean): Promise<PeerSnapshot[]> => {
|
|
176
207
|
const coordinator = getSharedMarketDataCoordinator();
|
|
@@ -189,8 +220,14 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
189
220
|
// Fundamentals come from one batched snapshot; prices stream on top of it.
|
|
190
221
|
// A changed peer list keeps the last snapshot up until its own answer.
|
|
191
222
|
const peerSnapshot = useAsyncResource(symbols.length > 0 ? loadPeers : null, { keepPreviousData: true });
|
|
192
|
-
const { data: peers,
|
|
193
|
-
const
|
|
223
|
+
const { data: peers, updatedAt, reload: reloadSnapshot } = peerSnapshot;
|
|
224
|
+
const loading = peerSnapshot.loading || pickingPeers;
|
|
225
|
+
const error = symbols.length > 0 ? peerSnapshot.error : pickingPeers ? null : "No tickers selected";
|
|
226
|
+
// Refreshing asks for the peers again when picking them failed.
|
|
227
|
+
const reload = useCallback(() => {
|
|
228
|
+
if (peerError) setPeerAttempt((attempt) => attempt + 1);
|
|
229
|
+
return reloadSnapshot();
|
|
230
|
+
}, [peerError, reloadSnapshot]);
|
|
194
231
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
195
232
|
const [sortPreference, setSortPreference] = useState<RelativeSortPreference>(DEFAULT_RELATIVE_SORT);
|
|
196
233
|
const appBaseCurrency = useAppSelector((state) => state.config.baseCurrency);
|
|
@@ -274,7 +311,7 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
274
311
|
|
|
275
312
|
const staleSymbols = rows.filter((row) => row.quoteStale).map((row) => row.symbol);
|
|
276
313
|
const rowErrors = rows.filter((row) => row.error).map((row) => `${row.symbol}: ${row.error}`);
|
|
277
|
-
const status = [error, ...rowErrors, staleSymbols.length ? `Stale quotes: ${staleSymbols.join(", ")}` : null,
|
|
314
|
+
const status = [error, peerError, ...rowErrors, staleSymbols.length ? `Stale quotes: ${staleSymbols.join(", ")}` : null,
|
|
278
315
|
missingFx ? "Market-cap FX unavailable" : null].filter(Boolean).join(" · ") || null;
|
|
279
316
|
|
|
280
317
|
usePaneNoticeFooter({
|
|
@@ -3,21 +3,58 @@ import type { HeadlessPaneContext } from "../../../types/headless";
|
|
|
3
3
|
import type { TickerFinancials } from "../../../types/financials";
|
|
4
4
|
import { isUsListingExchange, parsePublicTickerKey } from "../../../utils/exchanges";
|
|
5
5
|
import { errorMessage } from "../../../utils/errors";
|
|
6
|
+
import { isRecord } from "../../../utils/guards";
|
|
7
|
+
import { formatTickerListInput } from "../../../tickers/list";
|
|
6
8
|
import { selectMarketCapitalization } from "../../../utils/market-capitalization";
|
|
9
|
+
import { notATickerSymbol } from "../../../sources/provider-errors";
|
|
7
10
|
import { fetchScreen } from "../equity-screener/client";
|
|
11
|
+
import { loadHeadlessFinancials } from "../shared/headless-market-data";
|
|
8
12
|
import { loadFxLegs } from "./relative-valuation-currency";
|
|
9
13
|
|
|
14
|
+
/** The pane setting holding the peers chosen for its subject (`PeerSelection`), which the report reads back too. */
|
|
15
|
+
export const PEER_SELECTION_SETTING = "peerSelection";
|
|
16
|
+
/** The pane setting `--peers` sets, and a pane opened on one ticker: pick that ticker's peers. */
|
|
17
|
+
export const AUTO_PEERS_SETTING = "autoPeers";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What picking peers reads through: the subject's financials and FX rates
|
|
21
|
+
* from the market data client, the covered equities from the screener. The
|
|
22
|
+
* report passes its own context, the pane the app's clients, so both pick
|
|
23
|
+
* the same peers.
|
|
24
|
+
*/
|
|
25
|
+
export type PeerPickContext = Pick<HeadlessPaneContext, "signal" | "marketData" | "apiClient" | "resolveInstrument">;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* No peers could be picked. The message is the report's, which names
|
|
29
|
+
* `--peers` and the command that lists peers instead; `reason` is the pane's,
|
|
30
|
+
* which says only why.
|
|
31
|
+
*/
|
|
32
|
+
export class PeerPickError extends Error {
|
|
33
|
+
constructor(readonly reason: string, reportReason: string, symbol: string) {
|
|
34
|
+
super(`${reportReason} List them yourself: gloom fn RV ${symbol} <tickers>.`);
|
|
35
|
+
this.name = "PeerPickError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
/** How many peers `--peers` adds at most, and the fewest it settles for before it falls back. */
|
|
11
40
|
const MOST_PEERS = 6;
|
|
12
41
|
const FEWEST_PEERS = 4;
|
|
42
|
+
/**
|
|
43
|
+
* A peer of comparable size is at most this many times smaller than the
|
|
44
|
+
* subject: an airline keeps its smaller airlines, a $5T company is not shown
|
|
45
|
+
* beside $2M ones. A larger peer always counts; a small company is compared
|
|
46
|
+
* with its industry's leaders.
|
|
47
|
+
*/
|
|
48
|
+
const COMPARABLE_CAP_FACTOR = 100;
|
|
13
49
|
|
|
14
50
|
/**
|
|
15
51
|
* How the peers were picked: the subject's own industry, the industry its
|
|
16
|
-
* business belongs to when its profile files it under another,
|
|
52
|
+
* business belongs to when its profile files it under another, its
|
|
53
|
+
* industry's peers of comparable size and then its sector's, a curated
|
|
17
54
|
* group's core first (the subject is one of that core) and its industry
|
|
18
55
|
* after, or a curated list when too few covered equities can be read.
|
|
19
56
|
*/
|
|
20
|
-
type PeerMethod = "industry" | "related-industry" | "curated-core" | "curated";
|
|
57
|
+
type PeerMethod = "industry" | "related-industry" | "sector" | "curated-core" | "curated";
|
|
21
58
|
|
|
22
59
|
/** The peers `--peers` added to one symbol, and how; the pane and the report both read it. */
|
|
23
60
|
export interface PeerSelection {
|
|
@@ -32,6 +69,10 @@ export interface PeerSelection {
|
|
|
32
69
|
curatedPeers?: string[];
|
|
33
70
|
/** The curated group, as the header and the note name it (`online learning`). */
|
|
34
71
|
curatedGroup?: string;
|
|
72
|
+
/** The sector the rest of the peers come from, when too few in `industry` are of comparable size. */
|
|
73
|
+
sector?: string;
|
|
74
|
+
/** The peers of comparable size taken from `industry`, first in `peers`; the rest came from `sector`. */
|
|
75
|
+
industryPeers?: string[];
|
|
35
76
|
/** How the peers were chosen, read after "Peers chosen automatically: ". */
|
|
36
77
|
basis: string;
|
|
37
78
|
}
|
|
@@ -81,6 +122,8 @@ export interface PeerCandidate {
|
|
|
81
122
|
marketCapUsd: number | null;
|
|
82
123
|
/** It reports revenue figures (growth or a margin): a shell or a treasury vehicle filed in an industry does not. */
|
|
83
124
|
reportsOperations: boolean;
|
|
125
|
+
/** Its sector, as the screener names it. */
|
|
126
|
+
sector?: string | null;
|
|
84
127
|
}
|
|
85
128
|
|
|
86
129
|
/** Where the subject's peers come from: its own industry, or the related one its business belongs to. */
|
|
@@ -157,10 +200,76 @@ function listText(items: readonly string[]): string {
|
|
|
157
200
|
return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
158
201
|
}
|
|
159
202
|
|
|
160
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* The covered equities the screener returned for a subject's group: its
|
|
205
|
+
* industry's, its curated core's, and its sector's of comparable size when
|
|
206
|
+
* the industry has too few.
|
|
207
|
+
*/
|
|
161
208
|
export interface PeerCandidates {
|
|
162
209
|
industry: readonly PeerCandidate[];
|
|
163
210
|
core: readonly PeerCandidate[];
|
|
211
|
+
sector?: { name: string; candidates: readonly PeerCandidate[] } | null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** At least a hundredth of the subject's market cap. */
|
|
215
|
+
function comparableCap(subjectCap: number | null, cap: number | null): boolean {
|
|
216
|
+
return subjectCap != null && subjectCap > 0 && cap != null && cap > 0 && cap * COMPARABLE_CAP_FACTOR >= subjectCap;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function comparablePeers(
|
|
220
|
+
subject: Pick<PeerSubject, "symbol" | "marketCapUsd">,
|
|
221
|
+
candidates: readonly PeerCandidate[],
|
|
222
|
+
options?: { limit?: number; exclude?: readonly string[] },
|
|
223
|
+
): string[] {
|
|
224
|
+
return rankPeerCandidates(subject, candidates.filter((candidate) => comparableCap(subject.marketCapUsd, candidate.marketCapUsd)), options);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A subject of known size whose own industry (no curated group) has fewer
|
|
229
|
+
* than four covered equities of comparable size: a megacap among micro-caps,
|
|
230
|
+
* or a small industry. Its sector is asked for the rest.
|
|
231
|
+
*/
|
|
232
|
+
function needsSectorPeers(subject: PeerSubject, group: PeerGroup, industry: readonly PeerCandidate[]): boolean {
|
|
233
|
+
return !group.related && subject.marketCapUsd != null && subject.marketCapUsd > 0
|
|
234
|
+
&& comparablePeers(subject, industry).length < FEWEST_PEERS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The sector to widen to, as the screener files the subject, else as it files
|
|
239
|
+
* most of the subject's industry, else as the subject's profile names it.
|
|
240
|
+
*/
|
|
241
|
+
function peerSectorOf(subject: Pick<PeerSubject, "symbol">, industry: readonly PeerCandidate[], profileSector?: string | null): string | null {
|
|
242
|
+
const own = industry.find((candidate) => bareSymbol(candidate.symbol) === bareSymbol(subject.symbol))?.sector?.trim();
|
|
243
|
+
if (own) return own;
|
|
244
|
+
const counts = new Map<string, number>();
|
|
245
|
+
for (const candidate of industry) {
|
|
246
|
+
const sector = candidate.sector?.trim();
|
|
247
|
+
if (sector) counts.set(sector, (counts.get(sector) ?? 0) + 1);
|
|
248
|
+
}
|
|
249
|
+
return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? (profileSector?.trim() || null);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The industry's peers of comparable size first, then the sector's closest in
|
|
254
|
+
* market cap, also of comparable size, to six. Null when industry and sector
|
|
255
|
+
* together still have fewer than four: the industry's closest stand, as they
|
|
256
|
+
* would without a sector.
|
|
257
|
+
*/
|
|
258
|
+
function sectorSelection(subject: PeerSubject, group: PeerGroup, sector: PeerCandidates["sector"], industryPeers: string[]): PeerSelection | null {
|
|
259
|
+
if (!sector) return null;
|
|
260
|
+
const fill = comparablePeers(subject, sector.candidates, { limit: MOST_PEERS - industryPeers.length, exclude: industryPeers });
|
|
261
|
+
const peers = [...industryPeers, ...fill];
|
|
262
|
+
if (fill.length === 0 || peers.length < FEWEST_PEERS) return null;
|
|
263
|
+
const count = (names: readonly string[]) => (names.length === 1 ? "the covered equity" : `the ${names.length} covered equities`);
|
|
264
|
+
const fromSector = `${count(fill)} in the ${sector.name} sector closest to ${subject.symbol} in market cap`;
|
|
265
|
+
return {
|
|
266
|
+
subject: subject.symbol, peers, method: "sector", industry: group.industry, subjectIndustry: subject.industry,
|
|
267
|
+
sector: sector.name, industryPeers,
|
|
268
|
+
basis: industryPeers.length === 0
|
|
269
|
+
? `${fromSector}, as no other covered equity in ${group.industry} has a hundredth of its market cap`
|
|
270
|
+
: `${listText(industryPeers)}, ${count(industryPeers)} in ${group.industry} of at least a hundredth of ${subject.symbol}'s market cap; `
|
|
271
|
+
+ `${listText(fill)}, ${fromSector}`,
|
|
272
|
+
};
|
|
164
273
|
}
|
|
165
274
|
|
|
166
275
|
/**
|
|
@@ -168,7 +277,10 @@ export interface PeerCandidates {
|
|
|
168
277
|
* curated core is compared with the rest of that core first, closest in
|
|
169
278
|
* market cap, and the industry's closest fill the table to six. Any other
|
|
170
279
|
* subject takes the closest of its own industry, or of the related industry
|
|
171
|
-
* its business belongs to with that core among the candidates.
|
|
280
|
+
* its business belongs to with that core among the candidates. Its own
|
|
281
|
+
* industry's are the ones of at least a hundredth of its market cap; with
|
|
282
|
+
* fewer than four of those and the sector read, they come first and the
|
|
283
|
+
* sector's closest of comparable size fill the table. Fewer than
|
|
172
284
|
* four, or no candidates at all (`candidates` null: the screener did not
|
|
173
285
|
* answer), falls back to the related industry's curated core when it has
|
|
174
286
|
* one; otherwise what was found, or null when nothing was.
|
|
@@ -177,10 +289,15 @@ export function selectPeers(subject: PeerSubject, group: PeerGroup, candidates:
|
|
|
177
289
|
const symbol = subject.symbol;
|
|
178
290
|
const related = group.related;
|
|
179
291
|
const curatedCore = group.inCore && related ? (candidates ? rankPeerCandidates(subject, candidates.core) : []) : [];
|
|
292
|
+
// Its own industry's peers of comparable size; too few, and the sector's fill in when it was read.
|
|
293
|
+
const comparable = candidates && !related ? comparablePeers(subject, candidates.industry) : [];
|
|
294
|
+
const widened = candidates && !related && comparable.length < FEWEST_PEERS ? sectorSelection(subject, group, candidates.sector, comparable) : null;
|
|
295
|
+
if (widened) return widened;
|
|
180
296
|
const fill = candidates
|
|
181
297
|
? group.inCore
|
|
182
298
|
? rankPeerCandidates(subject, candidates.industry, { limit: MOST_PEERS - curatedCore.length, exclude: curatedCore })
|
|
183
|
-
:
|
|
299
|
+
: comparable.length >= FEWEST_PEERS ? comparable
|
|
300
|
+
: rankPeerCandidates(subject, [...candidates.industry, ...related ? candidates.core : []])
|
|
184
301
|
: [];
|
|
185
302
|
const peers = [...curatedCore, ...fill];
|
|
186
303
|
if (peers.length < FEWEST_PEERS && related) {
|
|
@@ -225,6 +342,10 @@ export function peerMethodTitle(selection: PeerSelection, { automatic = false }:
|
|
|
225
342
|
return `${peers} by industry: ${selection.industry}`;
|
|
226
343
|
case "related-industry":
|
|
227
344
|
return `${peers} by related industry: ${selection.industry}`;
|
|
345
|
+
case "sector":
|
|
346
|
+
return selection.industryPeers?.length
|
|
347
|
+
? `${peers} by industry: ${selection.industry}, then sector: ${selection.sector}`
|
|
348
|
+
: `${peers} by sector: ${selection.sector}`;
|
|
228
349
|
case "curated-core":
|
|
229
350
|
return selection.peers.length > (selection.curatedPeers?.length ?? 0)
|
|
230
351
|
? `${peers}: ${selection.curatedGroup} curated core, then ${selection.industry}`
|
|
@@ -234,6 +355,17 @@ export function peerMethodTitle(selection: PeerSelection, { automatic = false }:
|
|
|
234
355
|
}
|
|
235
356
|
}
|
|
236
357
|
|
|
358
|
+
/**
|
|
359
|
+
* An RV pane's title: the subject and how its peers were chosen
|
|
360
|
+
* (`RV AAPL + auto peers by industry: Consumer Electronics`), as a capture
|
|
361
|
+
* of `--peers` is titled, or the tickers as listed.
|
|
362
|
+
*/
|
|
363
|
+
export function relativeValuationTitle(symbols: readonly string[], selection: PeerSelection | null): string {
|
|
364
|
+
return selection
|
|
365
|
+
? `RV ${selection.subject} + ${peerMethodTitle(selection, { automatic: true })}`
|
|
366
|
+
: `RV ${formatTickerListInput([...symbols])}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
237
369
|
function screenSymbol(row: ScreenRow): string {
|
|
238
370
|
return isUsListingExchange(row.exchange) ? row.symbol : `${row.symbol}:${row.exchange}`;
|
|
239
371
|
}
|
|
@@ -245,16 +377,36 @@ function candidateOf(row: ScreenRow): PeerCandidate {
|
|
|
245
377
|
marketCapUsd: metric("marketCap"),
|
|
246
378
|
reportsOperations: metric("revenueGrowthPercent") != null || metric("grossMarginPercent") != null
|
|
247
379
|
|| metric("operatingMarginPercent") != null,
|
|
380
|
+
sector: row.sector,
|
|
248
381
|
};
|
|
249
382
|
}
|
|
250
383
|
|
|
251
|
-
async function screenRows(
|
|
252
|
-
|
|
253
|
-
|
|
384
|
+
async function screenRows(
|
|
385
|
+
criteria: ScreenDefinition["criteria"],
|
|
386
|
+
ctx: PeerPickContext,
|
|
387
|
+
{ direction = "desc", limit }: { direction?: "asc" | "desc"; limit?: number } = {},
|
|
388
|
+
): Promise<ScreenRow[]> {
|
|
389
|
+
const definition: ScreenDefinition = { version: 1, criteria, sort: { field: "marketCap", direction }, currency: "USD" };
|
|
390
|
+
return (await fetchScreen(definition, null, ctx.signal, ctx.apiClient, limit)).rows;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The sector's covered equities of comparable size nearest the subject's cap:
|
|
395
|
+
* the next larger and the next smaller, asked apart so a crowded sector
|
|
396
|
+
* cannot crowd out the closest.
|
|
397
|
+
*/
|
|
398
|
+
async function sectorCandidates(sector: string, capUsd: number, ctx: PeerPickContext): Promise<PeerCandidate[]> {
|
|
399
|
+
const inSector: ScreenDefinition["criteria"][number] = { field: "sector", op: "in", value: [sector] };
|
|
400
|
+
const [larger, smaller] = await Promise.all([
|
|
401
|
+
screenRows([inSector, { field: "marketCap", op: "gte", value: Math.floor(capUsd) }], ctx, { direction: "asc", limit: 25 }),
|
|
402
|
+
screenRows([inSector, { field: "marketCap", op: "between", value: [Math.floor(capUsd / COMPARABLE_CAP_FACTOR), Math.ceil(capUsd)] }],
|
|
403
|
+
ctx, { direction: "desc", limit: 25 }),
|
|
404
|
+
]);
|
|
405
|
+
return [...larger, ...smaller].map(candidateOf);
|
|
254
406
|
}
|
|
255
407
|
|
|
256
408
|
/** The subject's market cap in US dollars, through the app's FX rates when it is quoted in another currency. */
|
|
257
|
-
async function subjectCapUsd(financials: TickerFinancials | null, ctx:
|
|
409
|
+
async function subjectCapUsd(financials: TickerFinancials | null, ctx: PeerPickContext): Promise<number | null> {
|
|
258
410
|
const cap = selectMarketCapitalization(financials?.quote, financials?.fundamentals);
|
|
259
411
|
if (!cap) return null;
|
|
260
412
|
if (cap.currency === "USD") return cap.value;
|
|
@@ -266,27 +418,29 @@ async function subjectCapUsd(financials: TickerFinancials | null, ctx: HeadlessP
|
|
|
266
418
|
/**
|
|
267
419
|
* Picks the peers of `symbol` for `--peers`: its group from its symbol or
|
|
268
420
|
* profile, the group's covered equities from the screener (its industry, and
|
|
269
|
-
* a related industry's curated core
|
|
270
|
-
*
|
|
271
|
-
*
|
|
421
|
+
* a related industry's curated core, or its sector's of comparable size when
|
|
422
|
+
* the industry has too few), ranked as `selectPeers` says. Throws a
|
|
423
|
+
* `PeerPickError` when nothing places the subject, or nothing can be found.
|
|
272
424
|
*/
|
|
273
|
-
export async function pickPeers(symbol: string, financials: TickerFinancials | null, ctx:
|
|
425
|
+
export async function pickPeers(symbol: string, financials: TickerFinancials | null, ctx: PeerPickContext): Promise<PeerSelection> {
|
|
274
426
|
const subject: PeerSubject = {
|
|
275
427
|
symbol,
|
|
276
428
|
industry: financials?.profile?.industry?.trim() || null,
|
|
277
429
|
description: financials?.profile?.description ?? null,
|
|
278
430
|
marketCapUsd: null,
|
|
279
431
|
};
|
|
280
|
-
const listYourOwn = `List them yourself: gloom fn RV ${symbol} <tickers>.`;
|
|
281
432
|
const group = peerGroupOf(subject);
|
|
282
|
-
if (!group)
|
|
433
|
+
if (!group) {
|
|
434
|
+
throw new PeerPickError(`No peers: ${symbol}'s profile names no industry.`,
|
|
435
|
+
`${symbol}'s profile names no industry, so --peers cannot pick its peers.`, symbol);
|
|
436
|
+
}
|
|
283
437
|
let candidates: PeerCandidates | null = null;
|
|
284
438
|
let failure: string | null = null;
|
|
285
439
|
try {
|
|
286
440
|
const [capUsd, industryRows, coreRows] = await Promise.all([
|
|
287
441
|
subjectCapUsd(financials, ctx).catch(() => null),
|
|
288
|
-
screenRows({ field: "industry", op: "in", value: [group.industry] }, ctx),
|
|
289
|
-
group.related ? screenRows({ field: "symbol", op: "in", value: [...group.related.core] }, ctx) : Promise.resolve([]),
|
|
442
|
+
screenRows([{ field: "industry", op: "in", value: [group.industry] }], ctx),
|
|
443
|
+
group.related ? screenRows([{ field: "symbol", op: "in", value: [...group.related.core] }], ctx) : Promise.resolve([]),
|
|
290
444
|
]);
|
|
291
445
|
subject.marketCapUsd = capUsd;
|
|
292
446
|
candidates = { industry: industryRows.map(candidateOf), core: coreRows.map(candidateOf) };
|
|
@@ -294,9 +448,52 @@ export async function pickPeers(symbol: string, financials: TickerFinancials | n
|
|
|
294
448
|
ctx.signal.throwIfAborted();
|
|
295
449
|
failure = errorMessage(error);
|
|
296
450
|
}
|
|
451
|
+
// A megacap among micro-caps: the sector's companies of comparable size. Without them the industry's stand.
|
|
452
|
+
const sectorName = candidates && needsSectorPeers(subject, group, candidates.industry)
|
|
453
|
+
? peerSectorOf(subject, candidates.industry, financials?.profile?.sector) : null;
|
|
454
|
+
if (candidates && sectorName) {
|
|
455
|
+
const sector = await sectorCandidates(sectorName, subject.marketCapUsd!, ctx).catch(() => {
|
|
456
|
+
ctx.signal.throwIfAborted();
|
|
457
|
+
return null;
|
|
458
|
+
});
|
|
459
|
+
if (sector) candidates = { ...candidates, sector: { name: sectorName, candidates: sector } };
|
|
460
|
+
}
|
|
297
461
|
const selection = selectPeers(subject, group, candidates);
|
|
298
462
|
if (selection) return selection;
|
|
299
|
-
throw
|
|
300
|
-
?
|
|
301
|
-
|
|
463
|
+
throw failure
|
|
464
|
+
? new PeerPickError(`No peers: the covered equities in ${group.industry} could not be read (${failure}).`,
|
|
465
|
+
`--peers could not read the covered equities in ${group.industry}: ${failure}`, symbol)
|
|
466
|
+
: new PeerPickError(`No peers: no other covered equity is in ${group.industry}.`,
|
|
467
|
+
`No other covered equity is in ${group.industry}, so --peers found no peers for ${symbol}.`, symbol);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The peers of one symbol, as `fn RV <symbol> --peers` and the RV pane opened
|
|
472
|
+
* on one ticker both pick them: its financials for its industry, profile and
|
|
473
|
+
* market cap, then `pickPeers`. A word that is no ticker fails as such; any
|
|
474
|
+
* other failure to read the financials leaves the peers to what the symbol
|
|
475
|
+
* alone places (a curated core's member).
|
|
476
|
+
*/
|
|
477
|
+
export async function findPeers(symbol: string, ctx: PeerPickContext): Promise<PeerSelection> {
|
|
478
|
+
const financials = await loadHeadlessFinancials(ctx, symbol).catch((error: unknown) => {
|
|
479
|
+
ctx.signal.throwIfAborted();
|
|
480
|
+
// A word that is no ticker is said as `fn RV duolingo lrn` says it, which the CLI follows with the search to run.
|
|
481
|
+
if (notATickerSymbol(errorMessage(error))) throw error;
|
|
482
|
+
return null;
|
|
483
|
+
});
|
|
484
|
+
return pickPeers(symbol, financials, ctx);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The peers chosen for a list, as the expansion or the pane left them in its
|
|
489
|
+
* settings: null unless `symbols` is still the subject followed by those
|
|
490
|
+
* peers, in order.
|
|
491
|
+
*/
|
|
492
|
+
export function peerSelectionOf(value: unknown, symbols: readonly string[]): PeerSelection | null {
|
|
493
|
+
if (!isRecord(value) || typeof value.subject !== "string" || !Array.isArray(value.peers)
|
|
494
|
+
|| typeof value.method !== "string" || typeof value.basis !== "string") return null;
|
|
495
|
+
const selection = value as unknown as PeerSelection;
|
|
496
|
+
const expected = [selection.subject, ...selection.peers];
|
|
497
|
+
return expected.length === symbols.length && expected.every((symbol, index) => symbol.toUpperCase() === symbols[index]?.toUpperCase())
|
|
498
|
+
? selection : null;
|
|
302
499
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { PaneSettingsContext, PaneSettingsDef } from "../../../types/plugin";
|
|
2
|
+
import { formatTickerListInput, parseTickerListInput } from "../../../tickers/list";
|
|
3
|
+
import { AUTO_PEERS_SETTING, PEER_SELECTION_SETTING, peerSelectionOf } from "./relative-valuation-peers";
|
|
4
|
+
|
|
5
|
+
/** The most tickers RV compares, as it reads its argument. */
|
|
6
|
+
export const MAX_RELATIVE_VALUATION_TICKERS = 12;
|
|
7
|
+
|
|
8
|
+
/** The tickers an RV pane compares: the list in its settings, else the ticker it is bound to. */
|
|
9
|
+
export function relativeSymbolsFromPane(symbol: string | null, paneSettings: Record<string, unknown> | undefined): string[] {
|
|
10
|
+
const settingsSymbols = Array.isArray(paneSettings?.symbols)
|
|
11
|
+
? paneSettings.symbols.filter((value): value is string => typeof value === "string")
|
|
12
|
+
: [];
|
|
13
|
+
if (settingsSymbols.length > 0) return settingsSymbols;
|
|
14
|
+
return symbol ? [symbol] : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The pane's Tickers setting: the list it compares, with how its peers were
|
|
19
|
+
* chosen while they are the ones picked. A list typed there is compared as
|
|
20
|
+
* typed, one ticker alone included.
|
|
21
|
+
*/
|
|
22
|
+
export function buildRelativeValuationSettingsDef(context: PaneSettingsContext): PaneSettingsDef {
|
|
23
|
+
const symbols = relativeSymbolsFromPane(null, context.settings);
|
|
24
|
+
const selection = peerSelectionOf(context.settings[PEER_SELECTION_SETTING], symbols);
|
|
25
|
+
return {
|
|
26
|
+
title: "Relative Valuation Settings",
|
|
27
|
+
values: { symbolsText: formatTickerListInput(symbols) },
|
|
28
|
+
fields: [{
|
|
29
|
+
key: "symbolsText",
|
|
30
|
+
label: "Tickers",
|
|
31
|
+
description: selection
|
|
32
|
+
? `Peers chosen automatically: ${selection.basis}. Edit the list to compare your own.`
|
|
33
|
+
: `Enter up to ${MAX_RELATIVE_VALUATION_TICKERS} tickers separated by commas.`,
|
|
34
|
+
type: "text",
|
|
35
|
+
placeholder: "AAPL, MSFT, GOOGL",
|
|
36
|
+
}],
|
|
37
|
+
applyValue: (settings, field, value) => {
|
|
38
|
+
if (field.key !== "symbolsText") return { ...settings, [field.key]: value };
|
|
39
|
+
const next = parseTickerListInput(typeof value === "string" ? value : "", MAX_RELATIVE_VALUATION_TICKERS);
|
|
40
|
+
const { [PEER_SELECTION_SETTING]: _picked, ...rest } = settings;
|
|
41
|
+
return { ...rest, symbols: next, symbolsText: formatTickerListInput(next), [AUTO_PEERS_SETTING]: false };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "../../../api-client";
|
|
6
6
|
import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
|
|
7
7
|
import { listingAbroad, type IssuerListingParams } from "../../../api-client/paths";
|
|
8
|
+
import { servedFromSavedCopy } from "../../../data/saved-copies";
|
|
8
9
|
import type { HeadlessPaneApiClient, PluginPersistence } from "../../../types/plugin";
|
|
9
10
|
import { secListingKey } from "../../../utils/sec";
|
|
10
11
|
|
|
@@ -78,13 +79,14 @@ function loadCached<T extends object>(
|
|
|
78
79
|
}
|
|
79
80
|
const fallback = store?.getResource<T>(kind, key, { ...options, allowExpired: true });
|
|
80
81
|
if (!fallback) throw error;
|
|
81
|
-
|
|
82
|
+
// A command that shows it ends with when the copy was saved, not with the failure.
|
|
83
|
+
return servedFromSavedCopy({
|
|
82
84
|
...fallback.value,
|
|
83
85
|
fetchedAt: fallback.fetchedAt,
|
|
84
86
|
stale: true,
|
|
85
87
|
refreshError: (error instanceof Error ? error.message : String(error)).trim() || "Request failed",
|
|
86
88
|
errorStatus: error instanceof ApiRequestError ? error.status : undefined,
|
|
87
|
-
};
|
|
89
|
+
}, fallback.fetchedAt);
|
|
88
90
|
}).finally(() => { if (active.get(key) === request) active.delete(key); });
|
|
89
91
|
active.set(key, request);
|
|
90
92
|
return request.promise;
|