@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.
Files changed (203) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/hiring.ts +3 -0
  10. package/src/api-client/index.ts +4 -0
  11. package/src/api-client/ipo.ts +24 -1
  12. package/src/api-client/mna.ts +3 -0
  13. package/src/api-client/paths.ts +9 -1
  14. package/src/api-client/roadmap.ts +85 -0
  15. package/src/api-client/types.ts +12 -0
  16. package/src/architecture/core-builtin-allowlist.json +2 -2
  17. package/src/cli/commands/market.ts +35 -12
  18. package/src/cli/commands/news-search.ts +22 -0
  19. package/src/cli/commands/overview.ts +86 -20
  20. package/src/cli/commands/ticker.ts +32 -2
  21. package/src/cli/errors.ts +17 -2
  22. package/src/cli/glossary.ts +6 -0
  23. package/src/cli/index-roots.ts +0 -7
  24. package/src/cli/listing-arg.ts +57 -10
  25. package/src/cli/pane-functions/capabilities.ts +1 -0
  26. package/src/cli/pane-functions/discovery.ts +21 -1
  27. package/src/cli/pane-functions/freshness.ts +7 -1
  28. package/src/cli/pane-functions/function-help.ts +7 -0
  29. package/src/cli/pane-functions/index.ts +6 -3
  30. package/src/cli/quote-answer.ts +1 -1
  31. package/src/cli/saved-copy-persistence.ts +88 -0
  32. package/src/components/command-bar/list/model.ts +5 -0
  33. package/src/components/command-bar/routes/root/runtime.ts +6 -7
  34. package/src/components/command-bar/selection-runtime.ts +52 -3
  35. package/src/components/command-bar/surface/index.tsx +2 -0
  36. package/src/components/layout/pane/capture-stamp.ts +2 -2
  37. package/src/components/layout/pane/footer/index.tsx +22 -6
  38. package/src/components/layout/pane/footer/model.ts +83 -15
  39. package/src/components/layout/pane/status-footer.ts +35 -2
  40. package/src/components/layout/status-widget-space.ts +9 -0
  41. package/src/components/ticker/list-table-view.tsx +4 -20
  42. package/src/components/ui/display.tsx +18 -3
  43. package/src/components/ui/icon.tsx +4 -1
  44. package/src/data/plugin-cache.ts +5 -3
  45. package/src/data/saved-copies.ts +8 -0
  46. package/src/docs/snapshot.json +32 -5
  47. package/src/i18n/es.ts +2 -4
  48. package/src/i18n/ja.ts +2 -4
  49. package/src/i18n/ko.ts +2 -4
  50. package/src/i18n/zh-cn.ts +2 -4
  51. package/src/i18n/zh-tw.ts +2 -4
  52. package/src/market-data/depositary-pair.ts +25 -1
  53. package/src/market-data/quotes/metadata.ts +7 -0
  54. package/src/news/aggregator.ts +7 -1
  55. package/src/news/types.ts +6 -0
  56. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  57. package/src/plugins/builtin/alerts/cli.ts +34 -11
  58. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  59. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  60. package/src/plugins/builtin/alerts/headless.ts +10 -6
  61. package/src/plugins/builtin/cds/client.ts +142 -30
  62. package/src/plugins/builtin/cds/headless.ts +35 -8
  63. package/src/plugins/builtin/cds/model.ts +145 -18
  64. package/src/plugins/builtin/cds/pane.tsx +16 -5
  65. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  66. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  67. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  68. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  69. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  70. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  71. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  72. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  73. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  74. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  75. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  76. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  77. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  78. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  79. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  80. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  81. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  82. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  83. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  84. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  85. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  86. package/src/plugins/builtin/composite-plugins.ts +2 -1
  87. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  88. package/src/plugins/builtin/doe/headless.ts +3 -2
  89. package/src/plugins/builtin/doe/pane.tsx +13 -3
  90. package/src/plugins/builtin/earnings-calls/data.ts +18 -6
  91. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
  93. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  94. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  95. package/src/plugins/builtin/econ/detail-view.tsx +2 -2
  96. package/src/plugins/builtin/econ/figures.ts +48 -0
  97. package/src/plugins/builtin/econ/index.tsx +14 -5
  98. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  99. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  100. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  101. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  102. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  103. package/src/plugins/builtin/help/index.tsx +61 -59
  104. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  105. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  106. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  107. package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
  108. package/src/plugins/builtin/holders/headless.ts +4 -2
  109. package/src/plugins/builtin/holders/pane.tsx +3 -0
  110. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  111. package/src/plugins/builtin/insider/headless.ts +2 -2
  112. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  113. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  114. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  115. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  116. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  117. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  118. package/src/plugins/builtin/macro-day/model.ts +39 -5
  119. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  120. package/src/plugins/builtin/market-movers/client.ts +2 -0
  121. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  122. package/src/plugins/builtin/market-movers/screener.ts +8 -4
  123. package/src/plugins/builtin/mna/headless.ts +2 -0
  124. package/src/plugins/builtin/mna/pane.tsx +3 -1
  125. package/src/plugins/builtin/news/headless.ts +3 -1
  126. package/src/plugins/builtin/news/index.tsx +3 -0
  127. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  128. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  129. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  130. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  131. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  132. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  133. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  134. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  135. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  136. package/src/plugins/builtin/research/event-model.ts +30 -4
  137. package/src/plugins/builtin/research/index.tsx +7 -3
  138. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  139. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  140. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  141. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  142. package/src/plugins/builtin/risk-factors/data.ts +4 -2
  143. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  144. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  145. package/src/plugins/builtin/roadmap/model.ts +67 -0
  146. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  147. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  148. package/src/plugins/builtin/sec/headless.ts +2 -1
  149. package/src/plugins/builtin/sec/index.tsx +3 -0
  150. package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
  151. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  152. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  153. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  154. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  155. package/src/plugins/builtin/thirteenf/api.ts +12 -1
  156. package/src/plugins/builtin/thirteenf/data.ts +7 -7
  157. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  158. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  159. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  160. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  161. package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
  162. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  163. package/src/plugins/builtin/volatility/pane.tsx +5 -2
  164. package/src/plugins/builtin/yield-curve/client.ts +61 -1
  165. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  166. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  167. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  168. package/src/plugins/catalog-browser.ts +2 -1
  169. package/src/plugins/host-link.ts +1 -1
  170. package/src/portfolio/column-values.ts +11 -7
  171. package/src/renderers/dom/desktop/icons.tsx +7 -0
  172. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  173. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  174. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  175. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  176. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  177. package/src/renderers/electrobun/bun/index.ts +26 -2
  178. package/src/renderers/electrobun/bun/window/create.ts +43 -4
  179. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  180. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  181. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  182. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  183. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  184. package/src/renderers/electrobun/view/news-source.ts +2 -0
  185. package/src/renderers/electrobun/view/ui-host.tsx +8 -0
  186. package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
  187. package/src/sources/gloomberb-cloud/index.ts +42 -8
  188. package/src/tickers/company-name-listing.ts +53 -13
  189. package/src/tickers/company-name.ts +6 -1
  190. package/src/tickers/index-roots.ts +7 -0
  191. package/src/tickers/listing-fate.ts +2 -2
  192. package/src/tickers/search/index.ts +6 -2
  193. package/src/tickers/search/ranking.ts +5 -1
  194. package/src/tickers/search/upsert.ts +13 -0
  195. package/src/tickers/ticker-changes.ts +8 -4
  196. package/src/types/company-from.ts +40 -0
  197. package/src/types/data-provider.ts +7 -0
  198. package/src/types/financials.ts +27 -0
  199. package/src/types/headless.ts +6 -0
  200. package/src/utils/crypto-pair.ts +121 -7
  201. package/src/utils/declared-dividends.ts +1 -1
  202. package/src/utils/fundamentals.ts +36 -5
  203. 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 ?? "", 12);
174
+ : parseTickerListInput(options?.arg ?? context.activeTicker ?? "", MAX_RELATIVE_VALUATION_TICKERS);
172
175
  } catch {
173
176
  return null;
174
177
  }
175
178
  return {
176
- title: `RV ${formatTickerListInput(symbols)}`,
179
+ title: relativeValuationTitle(symbols, null),
177
180
  placement: "floating",
178
- settings: { symbols, symbolsText: formatTickerListInput(symbols) },
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 { pickPeers, peerMethodTitle, type PeerSelection } from "./relative-valuation-peers";
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
- const subject = symbols[0]!;
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: `RV ${selection.subject} + ${peerMethodTitle(selection, { automatic: true })}`,
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 symbols = useMemo(() => (symbolList ? symbolList.split(",") : []), [symbolList]);
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, loading, updatedAt, reload } = peerSnapshot;
193
- const error = symbols.length > 0 ? peerSnapshot.error : "No tickers selected";
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, a curated
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
- /** The covered equities the screener returned for a subject's group: its industry's, and its curated core's. */
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. Fewer than
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
- : rankPeerCandidates(subject, [...candidates.industry, ...related ? candidates.core : []])
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(criterion: ScreenDefinition["criteria"][number], ctx: HeadlessPaneContext): Promise<ScreenRow[]> {
252
- const definition: ScreenDefinition = { version: 1, criteria: [criterion], sort: { field: "marketCap", direction: "desc" }, currency: "USD" };
253
- return (await fetchScreen(definition, null, ctx.signal, ctx.apiClient)).rows;
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: HeadlessPaneContext): Promise<number | null> {
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), ranked as `selectPeers` says. Throws
270
- * when nothing places the subject, or nothing can be found, saying how to
271
- * list peers instead.
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: HeadlessPaneContext): Promise<PeerSelection> {
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) throw new Error(`${symbol}'s profile names no industry, so --peers cannot pick its peers. ${listYourOwn}`);
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 new Error(failure
300
- ? `--peers could not read the covered equities in ${group.industry}: ${failure} ${listYourOwn}`
301
- : `No other covered equity is in ${group.industry}, so --peers found no peers for ${symbol}. ${listYourOwn}`);
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
- return {
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;