gloomberb 0.17.2 → 0.17.4

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 (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -159,7 +159,10 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
159
159
  const error = symbols.length > 0 ? peerSnapshot.error : "No tickers selected";
160
160
  const [selectedIdx, setSelectedIdx] = useState(0);
161
161
  const [sortPreference, setSortPreference] = useState<RelativeSortPreference>(DEFAULT_RELATIVE_SORT);
162
- const baseCurrency = useAppSelector((state) => state.config.baseCurrency);
162
+ const appBaseCurrency = useAppSelector((state) => state.config.baseCurrency);
163
+ // `--currency` on a capture asks for another currency than the app's.
164
+ const capCurrency = typeof pane?.settings?.capCurrency === "string" ? pane.settings.capCurrency : null;
165
+ const baseCurrency = capCurrency ?? appBaseCurrency;
163
166
  const quoteTargets = useMemo<QuoteSubscriptionTarget[]>(() => symbols.map((peer) => ({
164
167
  symbol: peer,
165
168
  exchange: "",
@@ -209,8 +212,11 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
209
212
 
210
213
  usePaneNoticeFooter({
211
214
  registrationId: "relative-valuation-notices",
212
- notices: rows.filter((row) => row.fundamentalsProvenance?.stale)
213
- .map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
215
+ notices: [
216
+ ...rows.filter((row) => row.fundamentalsProvenance?.stale)
217
+ .map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
218
+ ...rows.flatMap((row) => row.evSalesUnavailable ? [`${row.symbol}: ${row.evSalesUnavailable}`] : []),
219
+ ],
214
220
  focused,
215
221
  });
216
222
 
@@ -237,7 +243,10 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
237
243
  case "forwardPE":
238
244
  return { text: formatPriceEarnings(row.notMeaningful.forwardPE ? NOT_MEANINGFUL : row.reportedMultiples.forwardPE), color: colors.text };
239
245
  case "evSales":
240
- return { text: formatNumber(row.evSales ?? undefined, 1), color: colors.text };
246
+ // A withdrawn EV says so; the footer says why.
247
+ return row.evSales == null && row.evSalesUnavailable
248
+ ? { text: "EV n/a", color: colors.textDim }
249
+ : { text: formatNumber(row.evSales ?? undefined, 1), color: colors.text };
241
250
  case "fcfYield":
242
251
  return { text: formatLevelPercent(row.fcfYield ?? undefined), color: priceColor(row.fcfYield ?? 0) };
243
252
  case "revenueGrowth":
@@ -0,0 +1,302 @@
1
+ import type { ScreenDefinition, ScreenRow } from "../../../api-client/equity-screener";
2
+ import type { HeadlessPaneContext } from "../../../types/headless";
3
+ import type { TickerFinancials } from "../../../types/financials";
4
+ import { isUsListingExchange, parsePublicTickerKey } from "../../../utils/exchanges";
5
+ import { errorMessage } from "../../../utils/errors";
6
+ import { selectMarketCapitalization } from "../../../utils/market-capitalization";
7
+ import { fetchScreen } from "../equity-screener/client";
8
+ import { loadFxLegs } from "./relative-valuation-currency";
9
+
10
+ /** How many peers `--peers` adds at most, and the fewest it settles for before it falls back. */
11
+ const MOST_PEERS = 6;
12
+ const FEWEST_PEERS = 4;
13
+
14
+ /**
15
+ * 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
17
+ * group's core first (the subject is one of that core) and its industry
18
+ * after, or a curated list when too few covered equities can be read.
19
+ */
20
+ type PeerMethod = "industry" | "related-industry" | "curated-core" | "curated";
21
+
22
+ /** The peers `--peers` added to one symbol, and how; the pane and the report both read it. */
23
+ export interface PeerSelection {
24
+ subject: string;
25
+ peers: string[];
26
+ method: PeerMethod;
27
+ /** The industry the peers come from, as the screener names it. */
28
+ industry: string | null;
29
+ /** The subject's own industry, as its profile names it. */
30
+ subjectIndustry: string | null;
31
+ /** The peers taken from a curated group's core, first in `peers`; the rest came from `industry`. */
32
+ curatedPeers?: string[];
33
+ /** The curated group, as the header and the note name it (`online learning`). */
34
+ curatedGroup?: string;
35
+ /** How the peers were chosen, read after "Peers chosen automatically: ". */
36
+ basis: string;
37
+ }
38
+
39
+ /**
40
+ * An industry whose companies are often filed under another: a subject
41
+ * filed under one of `filedUnder` whose profile opens on this business takes
42
+ * its peers from `industry` instead. Its curated `core` are the companies
43
+ * that compete head on: one of them is compared with the rest of the core
44
+ * first and the industry after, and they join every other subject's
45
+ * candidates.
46
+ */
47
+ interface RelatedIndustry {
48
+ /** The business, as the note names it (`its education business`). */
49
+ label: string;
50
+ industry: string;
51
+ filedUnder: readonly string[];
52
+ /** What the first sentence of the profile says when the company is in this business. */
53
+ business: RegExp;
54
+ /** The curated group the core forms, as the header and the note name it. */
55
+ coreLabel: string;
56
+ /** The curated core; also the peers used when the screener cannot answer, or covers fewer than four. */
57
+ core: readonly string[];
58
+ }
59
+
60
+ /** Kept small on purpose: an entry earns its place when an industry label hides who the competitors are. */
61
+ const RELATED_INDUSTRIES: readonly RelatedIndustry[] = [{
62
+ label: "education",
63
+ industry: "Education & Training Services",
64
+ filedUnder: ["Software - Application", "Software - Infrastructure", "Internet Content & Information"],
65
+ business: /\b(?:education|educational|e-?learning|online learning|language learning|learning platform|tutoring)\b/i,
66
+ coreLabel: "online learning",
67
+ core: ["DUOL", "COUR", "CHGG", "NRDY", "LRN", "UDMY"],
68
+ }];
69
+
70
+ /** The company `--peers` was given: its industry and business from its profile, and its market cap in US dollars. */
71
+ export interface PeerSubject {
72
+ symbol: string;
73
+ industry: string | null;
74
+ description: string | null;
75
+ marketCapUsd: number | null;
76
+ }
77
+
78
+ /** A covered equity the screener returned, with what the ranking reads. */
79
+ export interface PeerCandidate {
80
+ symbol: string;
81
+ marketCapUsd: number | null;
82
+ /** It reports revenue figures (growth or a margin): a shell or a treasury vehicle filed in an industry does not. */
83
+ reportsOperations: boolean;
84
+ }
85
+
86
+ /** Where the subject's peers come from: its own industry, or the related one its business belongs to. */
87
+ interface PeerGroup {
88
+ industry: string;
89
+ related: RelatedIndustry | null;
90
+ /** The subject is one of the related industry's curated core, which it is compared with first. */
91
+ inCore: boolean;
92
+ }
93
+
94
+ function bareSymbol(symbol: string): string {
95
+ return parsePublicTickerKey(symbol).symbol.toUpperCase();
96
+ }
97
+
98
+ /** A period that ends an abbreviation or an initial (`Inc.`, `N.V.`, `U.S.`), not a sentence. */
99
+ const ABBREVIATION_END = /(?:\b(?:inc|corp|co|ltd|plc|llc|lp|ag|se|nv|sa|no|st|mr|mrs|dr|jr|sr|vs|etc)|\b[a-z])\.$/i;
100
+
101
+ /**
102
+ * What a profile says the company does, before who it serves: its first
103
+ * sentence, read past the name's `Inc.` (`Duolingo, Inc. Class A Common Stock
104
+ * represents ownership in Duolingo, a technology company focused on...`).
105
+ */
106
+ function firstSentence(text: string | null): string {
107
+ const source = text ?? "";
108
+ for (const match of source.matchAll(/[.!?](?=\s+[A-Z])/g)) {
109
+ const head = source.slice(0, match.index + 1);
110
+ if (!ABBREVIATION_END.test(head)) return head;
111
+ }
112
+ return source;
113
+ }
114
+
115
+ export function peerGroupOf(subject: Pick<PeerSubject, "symbol" | "industry" | "description">): PeerGroup | null {
116
+ const symbol = bareSymbol(subject.symbol);
117
+ // A member of a curated core is placed by its symbol, whatever its profile says.
118
+ const core = RELATED_INDUSTRIES.find((group) => group.core.includes(symbol));
119
+ if (core) return { industry: core.industry, related: core, inCore: true };
120
+ const industry = subject.industry?.trim();
121
+ if (!industry) return null;
122
+ const related = RELATED_INDUSTRIES.find((group) => group.industry === industry
123
+ || (group.filedUnder.includes(industry) && group.business.test(firstSentence(subject.description))));
124
+ return related ? { industry: related.industry, related, inCore: false } : { industry, related: null, inCore: false };
125
+ }
126
+
127
+ /**
128
+ * The candidates closest to the subject in market cap, measured as a ratio
129
+ * (half its size and twice its size are as close), at most six. A candidate
130
+ * that reports no revenue figures is left out, and the subject itself.
131
+ * Without the subject's cap the largest come first.
132
+ */
133
+ export function rankPeerCandidates(
134
+ subject: Pick<PeerSubject, "symbol" | "marketCapUsd">,
135
+ candidates: readonly PeerCandidate[],
136
+ { limit = MOST_PEERS, exclude = [] }: { limit?: number; exclude?: readonly string[] } = {},
137
+ ): string[] {
138
+ const seen = new Set([bareSymbol(subject.symbol), ...exclude.map(bareSymbol)]);
139
+ const usable = candidates.filter((candidate) => {
140
+ const key = bareSymbol(candidate.symbol);
141
+ if (!candidate.reportsOperations || seen.has(key)) return false;
142
+ seen.add(key);
143
+ return true;
144
+ });
145
+ const base = subject.marketCapUsd != null && subject.marketCapUsd > 0 ? subject.marketCapUsd : null;
146
+ const distance = (cap: number | null) => (base && cap != null && cap > 0 ? Math.abs(Math.log(cap / base)) : Number.POSITIVE_INFINITY);
147
+ return usable
148
+ .sort((left, right) => distance(left.marketCapUsd) - distance(right.marketCapUsd)
149
+ || (right.marketCapUsd ?? 0) - (left.marketCapUsd ?? 0)
150
+ || left.symbol.localeCompare(right.symbol))
151
+ .slice(0, limit)
152
+ .map((candidate) => candidate.symbol);
153
+ }
154
+
155
+ /** `A`, `A and B`, `A, B and C`. */
156
+ function listText(items: readonly string[]): string {
157
+ return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
158
+ }
159
+
160
+ /** The covered equities the screener returned for a subject's group: its industry's, and its curated core's. */
161
+ export interface PeerCandidates {
162
+ industry: readonly PeerCandidate[];
163
+ core: readonly PeerCandidate[];
164
+ }
165
+
166
+ /**
167
+ * The peers of the subject, from the covered equities of its group. One of a
168
+ * curated core is compared with the rest of that core first, closest in
169
+ * market cap, and the industry's closest fill the table to six. Any other
170
+ * 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
172
+ * four, or no candidates at all (`candidates` null: the screener did not
173
+ * answer), falls back to the related industry's curated core when it has
174
+ * one; otherwise what was found, or null when nothing was.
175
+ */
176
+ export function selectPeers(subject: PeerSubject, group: PeerGroup, candidates: PeerCandidates | null): PeerSelection | null {
177
+ const symbol = subject.symbol;
178
+ const related = group.related;
179
+ const curatedCore = group.inCore && related ? (candidates ? rankPeerCandidates(subject, candidates.core) : []) : [];
180
+ const fill = candidates
181
+ ? group.inCore
182
+ ? rankPeerCandidates(subject, candidates.industry, { limit: MOST_PEERS - curatedCore.length, exclude: curatedCore })
183
+ : rankPeerCandidates(subject, [...candidates.industry, ...related ? candidates.core : []])
184
+ : [];
185
+ const peers = [...curatedCore, ...fill];
186
+ if (peers.length < FEWEST_PEERS && related) {
187
+ const curated = related.core.filter((peer) => peer !== bareSymbol(symbol)).slice(0, MOST_PEERS);
188
+ return {
189
+ subject: symbol, peers: curated, method: "curated", industry: related.industry, subjectIndustry: subject.industry,
190
+ curatedPeers: curated, curatedGroup: related.coreLabel,
191
+ basis: `the ${related.coreLabel} curated list (${listText(curated)}), as ${candidates ? `fewer than ${FEWEST_PEERS}` : "no"} covered equities in ${related.industry} could be read`,
192
+ };
193
+ }
194
+ if (peers.length === 0) return null;
195
+ // `the 6 covered equities in X closest to DUOL in market cap`, the fill after a core naming its own.
196
+ const closest = (names: readonly string[]) => {
197
+ const count = names.length === 1 ? "the covered equity" : `the ${names.length} covered equities`;
198
+ return `${names === peers ? "" : `${listText(names)}, `}${count} in ${group.industry} closest to ${symbol} in market cap`;
199
+ };
200
+ if (related && group.inCore && curatedCore.length > 0) {
201
+ return {
202
+ subject: symbol, peers, method: "curated-core", industry: group.industry, subjectIndustry: subject.industry,
203
+ curatedPeers: curatedCore, curatedGroup: related.coreLabel,
204
+ basis: `${listText(curatedCore)} from the ${related.coreLabel} curated core${fill.length > 0 ? `; ${closest(fill)}` : ""}`,
205
+ };
206
+ }
207
+ if (related && group.industry !== subject.industry) {
208
+ return {
209
+ subject: symbol, peers, method: "related-industry", industry: group.industry, subjectIndustry: subject.industry,
210
+ basis: `${closest(peers)}, where its ${related.label} business sits though its profile files it under ${subject.industry ?? "another industry"}`,
211
+ };
212
+ }
213
+ return { subject: symbol, peers, method: "industry", industry: group.industry, subjectIndustry: subject.industry, basis: closest(peers) };
214
+ }
215
+
216
+ /**
217
+ * The header's words for the peers: `peers by industry: Airlines`; a pane
218
+ * title, which has no note under it, says they were chosen for the reader
219
+ * (`auto peers by industry: Airlines`).
220
+ */
221
+ export function peerMethodTitle(selection: PeerSelection, { automatic = false }: { automatic?: boolean } = {}): string {
222
+ const peers = automatic ? "auto peers" : "peers";
223
+ switch (selection.method) {
224
+ case "industry":
225
+ return `${peers} by industry: ${selection.industry}`;
226
+ case "related-industry":
227
+ return `${peers} by related industry: ${selection.industry}`;
228
+ case "curated-core":
229
+ return selection.peers.length > (selection.curatedPeers?.length ?? 0)
230
+ ? `${peers}: ${selection.curatedGroup} curated core, then ${selection.industry}`
231
+ : `${peers}: ${selection.curatedGroup} curated core`;
232
+ case "curated":
233
+ return `${peers} from a curated list`;
234
+ }
235
+ }
236
+
237
+ function screenSymbol(row: ScreenRow): string {
238
+ return isUsListingExchange(row.exchange) ? row.symbol : `${row.symbol}:${row.exchange}`;
239
+ }
240
+
241
+ function candidateOf(row: ScreenRow): PeerCandidate {
242
+ const metric = (field: keyof ScreenRow["metrics"]) => row.metrics[field]?.value ?? null;
243
+ return {
244
+ symbol: screenSymbol(row),
245
+ marketCapUsd: metric("marketCap"),
246
+ reportsOperations: metric("revenueGrowthPercent") != null || metric("grossMarginPercent") != null
247
+ || metric("operatingMarginPercent") != null,
248
+ };
249
+ }
250
+
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;
254
+ }
255
+
256
+ /** 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> {
258
+ const cap = selectMarketCapitalization(financials?.quote, financials?.fundamentals);
259
+ if (!cap) return null;
260
+ if (cap.currency === "USD") return cap.value;
261
+ const { legs } = await loadFxLegs([cap.currency], ctx);
262
+ const rate = legs.get(cap.currency)?.rate;
263
+ return rate != null ? cap.value * rate : null;
264
+ }
265
+
266
+ /**
267
+ * Picks the peers of `symbol` for `--peers`: its group from its symbol or
268
+ * 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.
272
+ */
273
+ export async function pickPeers(symbol: string, financials: TickerFinancials | null, ctx: HeadlessPaneContext): Promise<PeerSelection> {
274
+ const subject: PeerSubject = {
275
+ symbol,
276
+ industry: financials?.profile?.industry?.trim() || null,
277
+ description: financials?.profile?.description ?? null,
278
+ marketCapUsd: null,
279
+ };
280
+ const listYourOwn = `List them yourself: gloomberb fn RV ${symbol} <tickers>.`;
281
+ const group = peerGroupOf(subject);
282
+ if (!group) throw new Error(`${symbol}'s profile names no industry, so --peers cannot pick its peers. ${listYourOwn}`);
283
+ let candidates: PeerCandidates | null = null;
284
+ let failure: string | null = null;
285
+ try {
286
+ const [capUsd, industryRows, coreRows] = await Promise.all([
287
+ 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([]),
290
+ ]);
291
+ subject.marketCapUsd = capUsd;
292
+ candidates = { industry: industryRows.map(candidateOf), core: coreRows.map(candidateOf) };
293
+ } catch (error) {
294
+ ctx.signal.throwIfAborted();
295
+ failure = errorMessage(error);
296
+ }
297
+ const selection = selectPeers(subject, group, candidates);
298
+ 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}`);
302
+ }
@@ -52,6 +52,17 @@ function documentKey(docType: CloudSearchDocType, sourceId: string): string {
52
52
  return `${docType}:${sourceId}`;
53
53
  }
54
54
 
55
+ export function cachedSearchDocument(
56
+ docType: CloudSearchDocType,
57
+ sourceId: string,
58
+ ): CloudSearchDocument | null {
59
+ return persistence?.getResource<CloudSearchDocument>(DOCUMENT_KIND, documentKey(docType, sourceId), {
60
+ sourceKey: CACHE_SOURCE,
61
+ schemaVersion: CACHE_SCHEMA_VERSION,
62
+ allowExpired: true,
63
+ })?.value ?? null;
64
+ }
65
+
55
66
  export async function loadSearchDocument(
56
67
  docType: CloudSearchDocType,
57
68
  sourceId: string,
@@ -41,6 +41,7 @@ import { usePlanAccess } from "../../../api-client/plan-access";
41
41
  import {
42
42
  createSavedSearch,
43
43
  deleteSavedSearch,
44
+ cachedSearchDocument,
44
45
  isAbortError,
45
46
  loadSavedSearches,
46
47
  loadSearchDocument,
@@ -147,6 +148,14 @@ export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps
147
148
  [hits, openHitId],
148
149
  );
149
150
  const openHit = openHitIndex >= 0 ? hits[openHitIndex]! : null;
151
+ // The document the cache holds paints at once; another document never
152
+ // shows under this hit's title while its own loads.
153
+ const [seededDocumentKey, setSeededDocumentKey] = useState<string | null>(null);
154
+ const openDocumentKey = openHit ? hitDocumentKey(openHit) : null;
155
+ if (seededDocumentKey !== openDocumentKey) {
156
+ setSeededDocumentKey(openDocumentKey);
157
+ setDocument(openHit ? cachedSearchDocument(openHit.docType, openHit.sourceId) : null);
158
+ }
150
159
 
151
160
  const focusField = useCallback((field: Exclude<ActiveField, null>) => {
152
161
  setActiveField(field);
@@ -165,7 +174,6 @@ export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps
165
174
  const controller = new AbortController();
166
175
  setDocumentLoading(true);
167
176
  setDocumentFailure(null);
168
- setDocument(null);
169
177
  void loadSearchDocument(openHit.docType, openHit.sourceId, controller.signal)
170
178
  .then((loaded) => {
171
179
  if (controller.signal.aborted) return;
@@ -173,7 +181,10 @@ export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps
173
181
  })
174
182
  .catch((error: unknown) => {
175
183
  if (controller.signal.aborted || isAbortError(error)) return;
176
- setDocumentFailure({ message: errorMessage(error), status: statusOf(error) });
184
+ const status = statusOf(error);
185
+ // Losing entitlement re-gates the document, not the copy the cache seeded.
186
+ if (status === 401 || status === 402 || status === 403) setDocument(null);
187
+ setDocumentFailure({ message: errorMessage(error), status });
177
188
  })
178
189
  .finally(() => {
179
190
  if (!controller.signal.aborted) setDocumentLoading(false);
@@ -4,7 +4,6 @@ import {
4
4
  DataTableView,
5
5
  PaneStatusBody,
6
6
  QueryBar,
7
- Tabs,
8
7
  type DataTableCell,
9
8
  type DataTableColumn,
10
9
  type DataTableKeyEvent,
@@ -125,9 +124,7 @@ function HiloPane({ focused, width, height }: PaneProps) {
125
124
  const showBars = !!feed.payload && height >= BARS_MIN_HEIGHT;
126
125
  // One cell of gutter keeps the two cursors from reading as a single wide row.
127
126
  const tableWidth = split ? Math.max(12, Math.floor((width - 1) / 2)) : Math.max(12, width);
128
- // A single table gets a strip naming its side, so the other side is visibly there.
129
- const sideStripRows = split ? 0 : 1;
130
- const tableHeight = Math.max(2, height - QUERY_BAR_HEIGHT - (showBars ? BARS_HEIGHT : 0) - sideStripRows);
127
+ const tableHeight = Math.max(2, height - QUERY_BAR_HEIGHT - (showBars ? BARS_HEIGHT : 0));
131
128
  const columns = useMemo(() => ({ lows: buildColumns(tableWidth, "lows"), highs: buildColumns(tableWidth, "highs") }), [tableWidth]);
132
129
 
133
130
  const handleSelect = useCallback((side: Side, row: ScannerHiloExtreme, index: number) => {
@@ -184,21 +181,14 @@ function HiloPane({ focused, width, height }: PaneProps) {
184
181
  options: MIN_PRICE_OPTIONS, onChange: setMinPrice },
185
182
  { id: "sort", label: "Sort", inline: true, value: sort, options: SORT_OPTIONS, onChange: setSort },
186
183
  ]}
184
+ view={split ? undefined : {
185
+ value: activeSide,
186
+ options: SIDE_TABS,
187
+ onChange: (value) => setActiveSide(value),
188
+ focused,
189
+ }}
187
190
  />
188
191
  {showBars && <HiloBars windows={feed.payload?.windows} width={width} />}
189
- {!split && (
190
- // Left and right (and h/l) switch sides, as they do between the split tables.
191
- <Box height={1} paddingX={1} flexShrink={0}>
192
- <Tabs
193
- tabs={SIDE_TABS}
194
- activeValue={activeSide}
195
- onSelect={(value) => setActiveSide(value as Side)}
196
- compact
197
- dense
198
- focused={focused}
199
- />
200
- </Box>
201
- )}
202
192
  <Box flexDirection="row" flexGrow={1} overflow="hidden">
203
193
  {split ? (
204
194
  <>
@@ -22,6 +22,29 @@ export const SEASONALITY_HISTORY_RESOLUTION: Record<SeasonalityCadence, ManualCh
22
22
  /** Daily history is served to five years; a longer request can come back as weekly bars. */
23
23
  const BUFFER_RANGE = { monthly: "ALL", daily: "5Y" } as const;
24
24
 
25
+ export function cachedSeasonalityHistory(
26
+ request: { instrument: InstrumentRef; cadence?: SeasonalityCadence },
27
+ ): SeasonalityHistory | null {
28
+ const coordinator = getSharedMarketDataCoordinator();
29
+ if (!coordinator) return null;
30
+ const cadence = request.cadence ?? "monthly";
31
+ const entry = coordinator.getChartEntry({
32
+ instrument: request.instrument,
33
+ bufferRange: BUFFER_RANGE[cadence],
34
+ granularity: "resolution",
35
+ resolution: SEASONALITY_HISTORY_RESOLUTION[cadence],
36
+ });
37
+ const history = resolveEntryValue(entry);
38
+ if (!history?.length) return null;
39
+ const now = Date.now();
40
+ return {
41
+ history,
42
+ stale: !!entry.error || (entry.staleAt != null && entry.staleAt <= now),
43
+ error: entry.error?.message ?? null,
44
+ fetchedAt: entry.fetchedAt ?? now,
45
+ };
46
+ }
47
+
25
48
  /**
26
49
  * Every month on record: the lookback is cut in the model, so changing it never
27
50
  * refetches. Monthly bars, because daily history is only served to 5Y where
@@ -14,7 +14,7 @@ import { useThemeColors } from "../../../theme/theme-context";
14
14
  import type { ResolvedSeries } from "../../../time-series/types";
15
15
  import type { PaneProps } from "../../../types/plugin";
16
16
  import { Box } from "../../../ui";
17
- import { loadSeasonalityHistory } from "./client";
17
+ import { cachedSeasonalityHistory, loadSeasonalityHistory } from "./client";
18
18
  import { type DailyReturnStat, MONTH_LABELS, OVERLAY_YEAR, projectSeasonality, projectWeekdays, type SeasonalityModel,
19
19
  TURN_OF_MONTH_LABELS, type WeekdayModel } from "./model";
20
20
 
@@ -156,7 +156,9 @@ export function SeasonalityPane({ width, height, focused }: PaneProps) {
156
156
  if (!snapshot.history.length && snapshot.error) throw new Error(snapshot.error);
157
157
  return snapshot;
158
158
  }, [instrumentKey, cadence]);
159
- const history = useAsyncResource(instrument ? loader : null);
159
+ const history = useAsyncResource(instrument ? loader : null, {
160
+ initialData: () => instrument ? cachedSeasonalityHistory({ instrument, cadence }) : null,
161
+ });
160
162
  useEffect(() => () => controller.current?.abort(), [loader]);
161
163
  useAutoRefresh(history.updatedAt, history.load);
162
164
  usePaneRefreshKey(() => { void history.reload(); }, { focused });
@@ -323,11 +323,15 @@ function EtfBoard({ collectionId, focused, width, height, rootBefore }: EtfBoard
323
323
  text: row.loading && row.return1Y === null ? "…" : row.return1Y !== null ? formatPercentRaw(shownPercent(row.return1Y)) : "—",
324
324
  color: row.return1Y !== null ? priceColor(shownPercent(row.return1Y)) : colors.textDim,
325
325
  };
326
- case "bar":
326
+ case "bar": {
327
+ const signed = row.changePercent !== null ? formatPercentRaw(shownPercent(row.changePercent)) : "—";
327
328
  return {
328
- text: "",
329
- content: <SectorMoveBar changePercent={row.changePercent} width={column.width} />,
329
+ text: signed,
330
+ content: <Box flexDirection="row" width={column.width} overflow="hidden" aria-label={signed}>
331
+ <SectorMoveBar changePercent={row.changePercent} width={column.width} />
332
+ </Box>,
330
333
  };
334
+ }
331
335
  }
332
336
  }, []);
333
337
 
@@ -1,7 +1,7 @@
1
1
  import type { HeadlessPaneFreshness } from "../../../types/headless";
2
- import type { Quote } from "../../../types/financials";
2
+ import type { Quote, QuoteIndexSession } from "../../../types/financials";
3
3
  import { isQuoteStaleForCurrentSession } from "../../../market-data/quotes/freshness";
4
- import { delayedFeedLagMs } from "../../../market-data/market/freshness";
4
+ import { delayedFeedLagMs, readIndexSession } from "../../../market-data/market/freshness";
5
5
  import { apiClient } from "../../../api-client";
6
6
  import { isPerpetualExchange } from "../../../tickers/perpetual-listing";
7
7
  import { CLOUD_NEWS_DELAY_HOURS, hasProAccess } from "../../../api-client/plan-rules";
@@ -56,6 +56,8 @@ interface QuoteFreshnessFields {
56
56
  /** The venue whose sessions the quote follows, so the report can say whether its market is open. */
57
57
  sessionExchange?: string;
58
58
  marketState?: Quote["marketState"];
59
+ /** An index's own regular sessions, for the open and the close its publisher's name does not place. */
60
+ sessionCalendar?: QuoteIndexSession;
59
61
  }
60
62
 
61
63
  /**
@@ -71,12 +73,14 @@ export function quoteFreshnessFields(
71
73
  const exchange = quote.listingExchangeName || quote.exchangeName;
72
74
  // A perpetual's venue is read about once a minute, not held back by a feed lag.
73
75
  const delayed = quote.dataSource === "delayed" && quote.providerId === CLOUD_QUOTE_PROVIDER_ID && !isPerpetualExchange(exchange);
76
+ const sessionCalendar = readIndexSession(quote.indexSession);
74
77
  return {
75
78
  ...(quote.dataSource === "live" || quote.dataSource === "delayed" ? { dataSource: quote.dataSource } : {}),
76
79
  stale: isQuoteStaleForCurrentSession(quote, now),
77
80
  ...(delayed ? { delayMinutes: delayedFeedLagMs(exchange) / 60_000 } : {}),
78
81
  ...(exchange ? { sessionExchange: exchange } : {}),
79
82
  ...(quote.marketState ? { marketState: quote.marketState } : {}),
83
+ ...(sessionCalendar ? { sessionCalendar } : {}),
80
84
  };
81
85
  }
82
86
 
@@ -5,13 +5,15 @@ import type {
5
5
  HeadlessPaneLoadArgs,
6
6
  } from "../../../types/plugin";
7
7
  import { formatCompact, formatNumber } from "../../../utils/format";
8
- import { loadShortInterest, type ShortInterestResult } from "./client";
8
+ import type { ShortInterestResult } from "./client";
9
9
  import {
10
10
  buildRows,
11
11
  sortRows,
12
12
  type SortPreference,
13
13
  } from "./model";
14
+ import { shortInterestFreshness } from "./schedule";
14
15
  import type { ShortInterestRecord } from "./types";
16
+ import { loadShortInterestWithFloat } from "./watch-client";
15
17
 
16
18
  const SHORT_INTEREST_COLUMNS: HeadlessPaneColumn[] = [
17
19
  { key: "settlementDate", header: "Date" },
@@ -23,7 +25,7 @@ const SHORT_INTEREST_COLUMNS: HeadlessPaneColumn[] = [
23
25
  },
24
26
  {
25
27
  key: "daysToCover",
26
- header: "Days to Cover",
28
+ header: "Days to cover",
27
29
  align: "right",
28
30
  format: (value) => value == null ? "-" : formatNumber(Number(value), 2),
29
31
  },
@@ -50,7 +52,7 @@ export interface ShortInterestHeadlessDependencies {
50
52
  }
51
53
 
52
54
  const defaultDependencies: ShortInterestHeadlessDependencies = {
53
- loadRecords: (symbol, _args, ctx) => loadShortInterest(symbol, ctx.apiClient),
55
+ loadRecords: (symbol, _args, ctx) => loadShortInterestWithFloat(symbol, { marketData: ctx.marketData, client: ctx.apiClient }),
54
56
  };
55
57
 
56
58
  export function createShortInterestHeadless(
@@ -65,6 +67,12 @@ export function createShortInterestHeadless(
65
67
  placeholder: "ticker",
66
68
  description: "US equity ticker.",
67
69
  },
70
+ discovery: {
71
+ limitations: [
72
+ "Borrow fee, utilization and locate availability are not available: FINRA short interest counts shares short, not what they cost to borrow.",
73
+ "Percent of float is the latest settlement's shares short over the current float; earlier settlements stay blank.",
74
+ ],
75
+ },
68
76
  usListingsOnly: "FINRA short interest",
69
77
  options: [
70
78
  {
@@ -107,8 +115,10 @@ export function createShortInterestHeadless(
107
115
  averageDailyVolume: row.record.averageDailyVolume,
108
116
  shortPercentFloat: row.record.shortPercentFloat,
109
117
  }));
118
+ const freshness = shortInterestFreshness(rows.map((row) => row.settlementDate).sort().at(-1));
110
119
  return {
111
120
  rows,
121
+ ...(freshness ? { freshness } : {}),
112
122
  ...(latestSettlements ? {
113
123
  complete: false,
114
124
  errors: [cloudSessionRequired
@@ -85,7 +85,7 @@ export const shortInterestModule: PluginModule = {
85
85
  label: "Short Interest",
86
86
  // The current snapshot only carries the current and prior settlement dates.
87
87
  description: "Bi-monthly short interest settlements from FINRA with days to cover and average daily volume. Daily volume is the other tab.",
88
- keywords: ["short", "interest", "si", "shorts", "borrow", "days", "cover"],
88
+ keywords: ["short", "interest", "si", "shorts", "days", "cover"],
89
89
  shortcut: "SI",
90
90
  }), "interest"),
91
91
  headless: shortInterestHeadless,