gloomberb 0.19.0 → 0.19.2

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 (304) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -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/errors.ts +38 -0
  10. package/src/api-client/hiring.ts +3 -0
  11. package/src/api-client/index.ts +4 -0
  12. package/src/api-client/ipo.ts +24 -1
  13. package/src/api-client/mna.ts +3 -0
  14. package/src/api-client/paths.ts +9 -1
  15. package/src/api-client/roadmap.ts +85 -0
  16. package/src/api-client/types.ts +12 -0
  17. package/src/app/runtime/startup.ts +3 -1
  18. package/src/architecture/core-builtin-allowlist.json +2 -2
  19. package/src/capabilities/chart-series.ts +2 -1
  20. package/src/cli/commands/market.ts +44 -16
  21. package/src/cli/commands/news-search.ts +22 -0
  22. package/src/cli/commands/overview.ts +84 -19
  23. package/src/cli/commands/search.ts +13 -13
  24. package/src/cli/commands/ticker.ts +32 -2
  25. package/src/cli/context.ts +1 -0
  26. package/src/cli/errors.ts +17 -2
  27. package/src/cli/glossary.ts +8 -1
  28. package/src/cli/history-rows.ts +35 -7
  29. package/src/cli/index-roots.ts +0 -7
  30. package/src/cli/known-gaps.ts +11 -0
  31. package/src/cli/listing-arg.ts +74 -11
  32. package/src/cli/pane-functions/capabilities.ts +1 -0
  33. package/src/cli/pane-functions/discovery.ts +21 -1
  34. package/src/cli/pane-functions/freshness.ts +7 -1
  35. package/src/cli/pane-functions/function-help.ts +7 -0
  36. package/src/cli/pane-functions/index.ts +6 -3
  37. package/src/cli/quote-answer.ts +30 -7
  38. package/src/cli/saved-copy-persistence.ts +88 -0
  39. package/src/components/command-bar/list/model.ts +5 -0
  40. package/src/components/command-bar/routes/root/runtime.ts +29 -8
  41. package/src/components/command-bar/selection-runtime.ts +64 -32
  42. package/src/components/command-bar/surface/index.tsx +2 -0
  43. package/src/components/layout/detached-pane-shell.tsx +2 -2
  44. package/src/components/layout/header.tsx +2 -2
  45. package/src/components/layout/pane/capture-stamp.ts +2 -2
  46. package/src/components/layout/pane/footer/index.tsx +23 -6
  47. package/src/components/layout/pane/footer/model.ts +116 -16
  48. package/src/components/layout/pane/header.tsx +2 -2
  49. package/src/components/layout/pane/status-footer.ts +37 -3
  50. package/src/components/layout/status-widget-space.ts +9 -0
  51. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  52. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  53. package/src/components/ticker/list-table-view.tsx +4 -20
  54. package/src/components/ui/display.tsx +29 -3
  55. package/src/components/ui/icon.tsx +12 -3
  56. package/src/core/app-services.ts +11 -2
  57. package/src/data/app-persistence.ts +9 -7
  58. package/src/data/plugin-cache.ts +20 -3
  59. package/src/data/plugin-state-store.ts +81 -24
  60. package/src/data/resource-store.ts +130 -58
  61. package/src/data/saved-copies.ts +8 -0
  62. package/src/data/session-store.ts +31 -8
  63. package/src/data/sqlite/access.ts +313 -0
  64. package/src/data/sqlite/checkpointer.ts +198 -0
  65. package/src/data/sqlite/database.ts +47 -4
  66. package/src/data/sqlite/retry.ts +50 -9
  67. package/src/data/ticker-store.ts +57 -23
  68. package/src/docs/snapshot.json +32 -5
  69. package/src/i18n/es.ts +2 -4
  70. package/src/i18n/ja.ts +2 -4
  71. package/src/i18n/ko.ts +2 -4
  72. package/src/i18n/zh-cn.ts +2 -4
  73. package/src/i18n/zh-tw.ts +2 -4
  74. package/src/market-data/depositary-pair.ts +25 -1
  75. package/src/market-data/quotes/metadata.ts +7 -0
  76. package/src/market-data/realized-volatility.ts +4 -2
  77. package/src/news/aggregator.ts +7 -1
  78. package/src/news/types.ts +6 -0
  79. package/src/plugins/auto-update-node.ts +10 -6
  80. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  81. package/src/plugins/builtin/alerts/cli.ts +34 -11
  82. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  83. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  84. package/src/plugins/builtin/alerts/headless.ts +10 -6
  85. package/src/plugins/builtin/cds/client.ts +142 -30
  86. package/src/plugins/builtin/cds/headless.ts +35 -8
  87. package/src/plugins/builtin/cds/model.ts +145 -18
  88. package/src/plugins/builtin/cds/pane.tsx +16 -5
  89. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  90. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  91. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  92. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  93. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  94. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  95. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  96. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  97. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  98. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  99. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  100. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  101. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  102. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  103. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  104. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  105. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  106. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  107. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  108. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  109. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  110. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  111. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  112. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  113. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  114. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  115. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  116. package/src/plugins/builtin/composite-plugins.ts +2 -1
  117. package/src/plugins/builtin/cot/client.ts +28 -2
  118. package/src/plugins/builtin/cot/headless.ts +4 -1
  119. package/src/plugins/builtin/cot/index.tsx +4 -2
  120. package/src/plugins/builtin/cot/model.ts +58 -7
  121. package/src/plugins/builtin/cot/pane.tsx +6 -2
  122. package/src/plugins/builtin/cpi/client.ts +6 -5
  123. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  124. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  125. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  126. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  127. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  128. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  129. package/src/plugins/builtin/doe/client.ts +6 -5
  130. package/src/plugins/builtin/doe/headless.ts +3 -2
  131. package/src/plugins/builtin/doe/pane.tsx +13 -3
  132. package/src/plugins/builtin/doe/prices.ts +2 -3
  133. package/src/plugins/builtin/doe/series.ts +4 -4
  134. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  135. package/src/plugins/builtin/earnings-calls/data.ts +37 -17
  136. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  137. package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
  138. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  139. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  140. package/src/plugins/builtin/econ/detail-view.tsx +18 -4
  141. package/src/plugins/builtin/econ/figures.ts +48 -0
  142. package/src/plugins/builtin/econ/index.tsx +14 -5
  143. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  144. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  145. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  146. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  147. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  148. package/src/plugins/builtin/executives/data.ts +6 -1
  149. package/src/plugins/builtin/executives/pane.tsx +9 -21
  150. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  151. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  152. package/src/plugins/builtin/gpu/client.ts +4 -1
  153. package/src/plugins/builtin/gpu/series.ts +2 -3
  154. package/src/plugins/builtin/help/index.tsx +61 -59
  155. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  156. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  157. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  158. package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
  159. package/src/plugins/builtin/holders/headless.ts +4 -2
  160. package/src/plugins/builtin/holders/pane.tsx +3 -0
  161. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  162. package/src/plugins/builtin/insider/headless.ts +2 -2
  163. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  164. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  165. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  166. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  167. package/src/plugins/builtin/jodi/client.ts +4 -1
  168. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  169. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  170. package/src/plugins/builtin/macro-day/model.ts +39 -5
  171. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  172. package/src/plugins/builtin/market-movers/client.ts +3 -0
  173. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  174. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  175. package/src/plugins/builtin/market-movers/screener.ts +27 -6
  176. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  177. package/src/plugins/builtin/mna/headless.ts +2 -0
  178. package/src/plugins/builtin/mna/pane.tsx +3 -1
  179. package/src/plugins/builtin/money-markets/series.ts +3 -5
  180. package/src/plugins/builtin/news/headless.ts +3 -1
  181. package/src/plugins/builtin/news/index.tsx +3 -0
  182. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  183. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  184. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  185. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  186. package/src/plugins/builtin/power/gas-client.ts +2 -1
  187. package/src/plugins/builtin/power/prices-client.ts +3 -2
  188. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  189. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  190. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  191. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  192. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  193. package/src/plugins/builtin/research/event-model.ts +30 -4
  194. package/src/plugins/builtin/research/index.tsx +7 -3
  195. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  196. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  197. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  198. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  199. package/src/plugins/builtin/risk-factors/data.ts +12 -6
  200. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  201. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  202. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  203. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  204. package/src/plugins/builtin/roadmap/model.ts +67 -0
  205. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  206. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  207. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  208. package/src/plugins/builtin/sec/headless.ts +2 -1
  209. package/src/plugins/builtin/sec/index.tsx +3 -0
  210. package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
  211. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  212. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  213. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  214. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  215. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  216. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  217. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  218. package/src/plugins/builtin/short-volume/series.ts +2 -3
  219. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  220. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  221. package/src/plugins/builtin/thirteenf/api.ts +15 -9
  222. package/src/plugins/builtin/thirteenf/data.ts +39 -11
  223. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  224. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  225. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  226. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  227. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  228. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  229. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  230. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  231. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  232. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  233. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  234. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  235. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  236. package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
  237. package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
  238. package/src/plugins/builtin/volatility/client.ts +43 -16
  239. package/src/plugins/builtin/volatility/headless.ts +5 -2
  240. package/src/plugins/builtin/volatility/model.ts +17 -6
  241. package/src/plugins/builtin/volatility/pane.tsx +11 -4
  242. package/src/plugins/builtin/volatility/series.ts +2 -1
  243. package/src/plugins/builtin/yield-curve/client.ts +59 -1
  244. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  245. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  246. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  247. package/src/plugins/catalog-browser.ts +2 -1
  248. package/src/plugins/host-link.ts +1 -1
  249. package/src/plugins/installer.ts +50 -39
  250. package/src/portfolio/column-values.ts +11 -7
  251. package/src/renderers/dom/desktop/icons.tsx +10 -2
  252. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  253. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  254. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  255. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  256. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  257. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  258. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  259. package/src/renderers/electrobun/bun/index.ts +26 -2
  260. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  261. package/src/renderers/electrobun/bun/window/create.ts +45 -4
  262. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  263. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  264. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  265. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  266. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  267. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  268. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  269. package/src/renderers/electrobun/view/news-source.ts +2 -0
  270. package/src/renderers/electrobun/view/ui-host.tsx +16 -1
  271. package/src/series/resolve.ts +2 -1
  272. package/src/series/types.ts +5 -0
  273. package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
  274. package/src/sources/gloomberb-cloud/index.ts +42 -8
  275. package/src/telemetry/stall-reports-node.ts +13 -2
  276. package/src/telemetry/stall-reports.ts +189 -13
  277. package/src/test-support/outage.ts +8 -0
  278. package/src/tickers/company-name-listing.ts +110 -40
  279. package/src/tickers/company-name.ts +71 -2
  280. package/src/tickers/index-roots.ts +7 -0
  281. package/src/tickers/listing-fate.ts +2 -2
  282. package/src/tickers/search/home-listing.ts +93 -8
  283. package/src/tickers/search/index.ts +7 -3
  284. package/src/tickers/search/ranking.ts +5 -1
  285. package/src/tickers/search/upsert.ts +13 -0
  286. package/src/tickers/ticker-changes.ts +8 -4
  287. package/src/time-series/market.ts +5 -3
  288. package/src/time-series/price-comparison.ts +20 -2
  289. package/src/time-series/resolve.ts +20 -1
  290. package/src/time-series/types.ts +7 -0
  291. package/src/types/company-from.ts +40 -0
  292. package/src/types/data-provider.ts +7 -0
  293. package/src/types/financials.ts +45 -3
  294. package/src/types/headless.ts +6 -0
  295. package/src/ui/host.tsx +6 -1
  296. package/src/utils/crypto-pair.ts +121 -7
  297. package/src/utils/declared-dividends.ts +1 -1
  298. package/src/utils/financial-statements.ts +73 -16
  299. package/src/utils/fundamentals.ts +53 -10
  300. package/src/utils/lease-liabilities.ts +54 -0
  301. package/src/utils/main-thread-stall.ts +47 -2
  302. package/src/utils/perf-marks.ts +23 -11
  303. package/src/utils/price-history-integrity.ts +47 -1
  304. package/src/version.ts +2 -2
@@ -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
+ }
@@ -3,8 +3,9 @@ import {
3
3
  type CloudRiskReportListPayload,
4
4
  type CloudRiskReportPayload,
5
5
  } from "../../../api-client";
6
- import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
6
+ import { ApiRequestError, isPermanentClientError, isTransientFailure } 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
 
@@ -20,6 +21,8 @@ type RiskApiClient = Pick<HeadlessPaneApiClient, "getRiskReports" | "getRiskRepo
20
21
  interface RiskFreshness {
21
22
  fetchedAt: number;
22
23
  stale: boolean;
24
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
25
+ savedAt?: number;
23
26
  refreshError?: string;
24
27
  errorStatus?: number;
25
28
  }
@@ -71,20 +74,23 @@ function loadCached<T extends object>(
71
74
  // A failed forced refresh must be retried when this pane is reopened, even
72
75
  // when the previously cached value has not reached its ordinary TTL yet.
73
76
  if (current()) failedRefreshes.set(failedKey, store);
74
- // A missing or denied resource must not be replaced by previously cached content.
75
- if (isPermanentClientError(error)) {
76
- if (current()) store?.deleteResource(kind, key, { sourceKey: CACHE_SOURCE });
77
+ // A saved copy answers only for a failure asking again may get past: a refusal or a reply that
78
+ // fails validation is the error, and a missing or denied resource also drops its copy.
79
+ if (!isTransientFailure(error)) {
80
+ if (isPermanentClientError(error) && current()) store?.deleteResource(kind, key, { sourceKey: CACHE_SOURCE });
77
81
  throw error;
78
82
  }
79
83
  const fallback = store?.getResource<T>(kind, key, { ...options, allowExpired: true });
80
84
  if (!fallback) throw error;
81
- return {
85
+ // A command that shows it ends with when the copy was saved, not with the failure.
86
+ return servedFromSavedCopy({
82
87
  ...fallback.value,
83
88
  fetchedAt: fallback.fetchedAt,
84
89
  stale: true,
90
+ savedAt: fallback.fetchedAt,
85
91
  refreshError: (error instanceof Error ? error.message : String(error)).trim() || "Request failed",
86
92
  errorStatus: error instanceof ApiRequestError ? error.status : undefined,
87
- };
93
+ }, fallback.fetchedAt);
88
94
  }).finally(() => { if (active.get(key) === request) active.delete(key); });
89
95
  active.set(key, request);
90
96
  return request.promise;
@@ -1,4 +1,5 @@
1
1
  import type { HeadlessBundleSection, HeadlessPaneDefinition } from "../../../types/plugin";
2
+ import { savedCopyMark, unansweredRefreshError } from "../../../data/saved-copies";
2
3
  import { loadRiskReportWithClient, loadRiskReportsWithClient, type RiskReportsResult } from "./data";
3
4
  import { SEC_FILINGS } from "../shared/report-freshness";
4
5
  import { resolveHeadlessIssuerListing } from "../shared/headless-market-data";
@@ -39,7 +40,8 @@ export const riskFactorsHeadless: HeadlessPaneDefinition<"bundle"> = {
39
40
  }
40
41
  const report = await loadRiskReportWithClient(context.apiClient, ticker, year, { force, listing });
41
42
  const sections: HeadlessBundleSection[] = [
42
- { title: "Filing", entries: [
43
+ // A report served from a saved copy dates the report by when it was saved (the list does in the metadata).
44
+ { title: "Filing", ...savedCopyMark(report), entries: [
43
45
  { label: "Ticker", value: report.ticker },
44
46
  { label: "Filing year", value: report.reportYear },
45
47
  { label: "Filed", value: report.filedAt },
@@ -62,12 +64,13 @@ export const riskFactorsHeadless: HeadlessPaneDefinition<"bundle"> = {
62
64
  { key: "group", header: "Group" }, { key: "heading", header: "Heading" },
63
65
  { key: "excerpt", header: "Prior source excerpt" }, { key: "note", header: "Analysis" },
64
66
  ], rows: report.diff.removed.map((risk, index) => ({ ...risk, note: report.notes.removed.find((note) => note.index === index)?.text ?? null })) });
65
- const errors = [list?.refreshError, report.refreshError].filter((error): error is string => !!error);
67
+ const errors = [unansweredRefreshError(list), unansweredRefreshError(report)].filter((error): error is string => !!error);
66
68
  return {
67
69
  sections,
68
70
  errors: errors.length ? errors : undefined,
69
71
  ...(list?.stale || report.stale ? { complete: false } : {}),
70
72
  metadata: {
73
+ ...savedCopyMark(list),
71
74
  ticker: report.ticker, requestedYear: selection, reportYear: report.reportYear,
72
75
  filedAt: report.filedAt, updatedAt: report.updatedAt, docUrl: report.docUrl,
73
76
  reportFetchedAt: report.fetchedAt, reportStale: report.stale,
@@ -21,7 +21,8 @@ const description =
21
21
  * filing list, which needs no account: `filings` lists them with a link to
22
22
  * each, where Item 1A is the risk factors as written, without RISK's grouping
23
23
  * or its changes since the year before. A foreign issuer files a 20-F instead
24
- * (ASML), whose Item 3.D holds them.
24
+ * (ASML), whose Item 3.D holds them. A company that files neither (IAG on
25
+ * LSE) has nothing in RISK either, and the note says so.
25
26
  */
26
27
  function riskFactorsLockNote(lock: PaneLock): Promise<string> {
27
28
  return proLockNote(lock, {
@@ -35,6 +36,7 @@ function riskFactorsLockNote(lock: PaneLock): Promise<string> {
35
36
  text: (ticker, company) => `gloom filings ${ticker} --form 20-F lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 3.D holds the risk factors as written.`,
36
37
  answers: (symbol, context) => filesForm(symbol, "20-F", context),
37
38
  }],
39
+ noneFor: (ticker) => `${ticker}'s company files no 10-K or 20-F with the SEC, so RISK has no risk factors for it.`,
38
40
  });
39
41
  }
40
42