gloomberb 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { FieldGrid, KeyValueRow, QueryBar, usePaneFooter, usePaneNoticeFooter, type GridField } from "../../../components";
3
3
  import { CompositeChart } from "../../../components/chart/composite/composite-chart";
4
+ import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
4
5
  import { useAsyncResource, useInputCapture } from "../../../public/react";
5
6
  import { useShortcut } from "../../../react/input";
6
7
  import { useAppSelector, usePaneInstance, usePaneStateValue } from "../../../state/app/context";
@@ -262,9 +263,6 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
262
263
  } else if (american && isPlainKey(event, "d")) {
263
264
  consume();
264
265
  setActiveFieldId("dividends");
265
- } else if (surfaceSource && isPlainKey(event, "r")) {
266
- consume();
267
- void surfaceResource.reload();
268
266
  }
269
267
  }, {
270
268
  allowEditable: true,
@@ -273,6 +271,7 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
273
271
  scope: "options-calculator:fields",
274
272
  });
275
273
 
274
+ usePaneRefreshKey(() => { void surfaceResource.reload(); }, { focused: focused && !commandBarOpen && surfaceSource && activeFieldId === null });
276
275
  usePaneFooter(OPTIONS_CALCULATOR_PANE_ID, () => ({
277
276
  info: [
278
277
  ...(problem ? [{ id: "input", parts: [{ text: problem, tone: "warning" as const }] }] : []),
@@ -65,12 +65,13 @@ interface CollectionRef {
65
65
  /**
66
66
  * One line per symbol and the summary under them; `--json` and `--csv` give
67
67
  * the rows, with the summary in the JSON metadata and on standard error beside
68
- * CSV. The command exits non-zero when no symbol ended up as asked.
68
+ * CSV. A separator dropped from a symbol of the list is named as a warning. The
69
+ * command exits non-zero when no symbol ended up as asked.
69
70
  */
70
71
  export function printSymbolRows(
71
72
  ctx: CliCommandContext,
72
73
  rows: SymbolRow[],
73
- { verb, collection, repeated }: { verb: Verb; collection: CollectionRef; repeated: number },
74
+ { verb, collection, repeated, strays }: { verb: Verb; collection: CollectionRef; repeated: number; strays: readonly string[] },
74
75
  ): void {
75
76
  const summary = `${collection.kind === "watchlist" ? "Watchlist" : "Portfolio"} "${collection.name}": ${summarizeSymbolRows(rows, verb, repeated)}`;
76
77
  const count = (status: SymbolStatus) => rows.filter((row) => row.status === status).length;
@@ -80,6 +81,7 @@ export function printSymbolRows(
80
81
  ctx.printResult({
81
82
  data: rows,
82
83
  metadata: { [collection.kind]: collection.name, ...counts, repeated, summary },
84
+ ...strays.length > 0 ? { warnings: [...strays] } : {},
83
85
  }, {
84
86
  text: () => [...rows.map((row) => rowLines(row, verb)), "", summary].join("\n"),
85
87
  });
@@ -102,13 +104,14 @@ export async function requireSymbolList(
102
104
  args: readonly string[],
103
105
  file: string | undefined,
104
106
  usage: string,
105
- ): Promise<{ symbols: string[]; repeated: number; single: string | null }> {
107
+ ): Promise<{ symbols: string[]; repeated: number; strays: string[]; single: string | null }> {
106
108
  try {
107
109
  const list = await readSymbolList({ args, file });
108
110
  if (list.symbols.length === 0) ctx.fail(usage);
109
- // One symbol typed alone keeps the single-symbol output and errors.
111
+ // One symbol typed alone keeps the single-symbol output and errors. It goes as typed, so a separator
112
+ // left on it (`UPST;`) is named by the listing it resolves to.
110
113
  const typed = splitSymbolArgs(args);
111
- const single = file == null && !args.includes("-") && typed.length === 1 ? typed[0]! : null;
114
+ const single = file == null && !args.includes("-") && typed.length === 1 ? (args.length === 1 ? args[0]!.trim() : typed[0]!) : null;
112
115
  return { ...list, single };
113
116
  } catch (error) {
114
117
  if (error instanceof SymbolListError) ctx.fail(error.message, usage);
@@ -51,6 +51,7 @@ import {
51
51
  misplacedSymbolList,
52
52
  parseFiniteNumber,
53
53
  PortfolioCliError,
54
+ refuseDryRun,
54
55
  rejectCashSymbol,
55
56
  requireManualPortfolio,
56
57
  requirePortfolio,
@@ -208,7 +209,7 @@ async function addToPortfolioCommand(args: string[], file: string | undefined, e
208
209
  retry: (key) => `gloomberb portfolio add ${/\s/.test(portfolio.name) ? `"${portfolio.name}"` : portfolio.name} ${key}`,
209
210
  add: (ticker) => addTickerToPortfolio(ticker, portfolio.id),
210
211
  });
211
- printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated });
212
+ printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated, strays: list.strays });
212
213
  });
213
214
  }
214
215
 
@@ -275,7 +276,7 @@ async function removeFromPortfolioCommand(args: string[], file: string | undefin
275
276
  },
276
277
  });
277
278
  if (nextConfig !== context.config) await saveConfig(nextConfig);
278
- printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated });
279
+ printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated, strays: list.strays });
279
280
  });
280
281
  }
281
282
 
@@ -814,7 +815,7 @@ export const portfolioCliCommand: CliCommandDef = {
814
815
  const importOnly = args.find((arg) => IMPORT_FLAGS.has(arg.split("=")[0]!));
815
816
  if (importOnly) ctx.fail(`${importOnly.split("=")[0]} is an option of portfolio import, not portfolio ${action}.`, IMPORT_USAGE);
816
817
  const mutating = MUTATING_ACTIONS.has(action ?? "") || ((action === "target" || action === "targets") && (args[1] === "set" || args[1] === "clear"));
817
- if (ctx.cliOptions.dryRun && mutating) ctx.fail(`portfolio ${action} has no --dry-run; it changes the portfolio when it runs.`);
818
+ if (mutating) refuseDryRun(ctx, "portfolio", action!);
818
819
 
819
820
  if (!action || action === "list") {
820
821
  await listCollections(ctx);
@@ -24,6 +24,15 @@ export function failPortfolioCommand(ctx: CliCommandContext, error: unknown, fal
24
24
  return ctx.fail(fallback);
25
25
  }
26
26
 
27
+ /**
28
+ * Fails an action that changes a portfolio or watchlist when `--dry-run` was
29
+ * given. None of them previews, so running it anyway would save the change
30
+ * under a flag that promises not to; this fails before anything is written.
31
+ */
32
+ export function refuseDryRun(ctx: CliCommandContext, noun: "watchlist" | "portfolio", action: string): void {
33
+ if (ctx.cliOptions.dryRun) ctx.fail(`${noun} ${action} has no --dry-run; it changes the ${noun} when it runs.`);
34
+ }
35
+
27
36
  /**
28
37
  * The portfolio or watchlist the leading arguments name, and the arguments
29
38
  * after it. The first argument is the name (quoted when it has spaces); an
@@ -22,12 +22,14 @@ import {
22
22
  removeTickerFromWatchlist as removeWatchlistMembership,
23
23
  } from "../mutations";
24
24
  import { showCollection } from "./render";
25
- import { listingFields, misplacedSymbolList, takeCollectionName } from "./shared";
25
+ import { listingFields, misplacedSymbolList, refuseDryRun, takeCollectionName } from "./shared";
26
26
  import { addSymbols, FILE_OPTION, printSymbolRows, removeSymbols, requireSymbolList, retryNotice, SYMBOL_LIST_HELP } from "./bulk-symbols";
27
27
  import { CLI_COMMAND_GROUPS } from "../../../../cli/help";
28
28
 
29
29
  const ADD_USAGE = "Usage: gloomberb watchlist add <watchlist> <symbols...> [--file <path>]";
30
30
  const REMOVE_USAGE = "Usage: gloomberb watchlist remove <watchlist> <symbols...> [--file <path>]";
31
+ /** Actions that change a watchlist: none of them previews, so --dry-run would change it anyway. */
32
+ const MUTATING_ACTIONS = new Set(["create", "delete", "rm", "add", "remove"]);
31
33
 
32
34
  /** The watchlist the leading arguments name and the symbols after it; fails naming the watchlists there are. */
33
35
  function requireWatchlistArgs(
@@ -134,7 +136,7 @@ async function addToWatchlistCommand(args: string[], file: string | undefined, e
134
136
  suggest: true,
135
137
  add: (ticker) => addTickerToWatchlist(ticker, watchlist.id),
136
138
  });
137
- printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated });
139
+ printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated, strays: list.strays });
138
140
  });
139
141
  }
140
142
 
@@ -167,7 +169,7 @@ async function removeFromWatchlistCommand(args: string[], file: string | undefin
167
169
  store: context.store,
168
170
  remove: (ticker) => removeWatchlistMembership(ticker, watchlist.id),
169
171
  });
170
- printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated });
172
+ printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated, strays: list.strays });
171
173
  });
172
174
  }
173
175
 
@@ -246,6 +248,7 @@ export const watchlistCliCommand: CliCommandDef = {
246
248
  const file = hasFile ? takeOption(args, "--file") ?? "" : undefined;
247
249
  const action = args[0];
248
250
  if (file != null && action !== "add" && action !== "remove") ctx.fail(`--file is an option of watchlist add and remove, not watchlist ${action ?? "list"}.`, ADD_USAGE);
251
+ if (MUTATING_ACTIONS.has(action ?? "")) refuseDryRun(ctx, "watchlist", action!);
249
252
 
250
253
  if (!action || action === "list") {
251
254
  await listWatchlists(ctx);
@@ -10,22 +10,20 @@ import type {
10
10
  import { apiClient } from "../../../api-client";
11
11
  import { ApiRequestError } from "../../../api-client/errors";
12
12
  import { Button, ChoiceDialog, EmptyState, PaneStatusBody, SectionHeading, Spinner, usePaneFooter, usePaneMenuItems } from "../../../components";
13
+ import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
13
14
  import { ExternalLinkText, PaneLinkMenu } from "../../../components/ui";
14
15
  import { t, tf } from "../../../i18n";
15
- import { useShortcut } from "../../../react/input";
16
16
  import { colors } from "../../../theme/colors";
17
17
  import { Box, ScrollBox, Text, TextAttributes, useRendererHost, useUiCapabilities } from "../../../ui";
18
18
  import { useOptionalDialog, type PromptContext } from "../../../ui/dialog";
19
19
  import { formatTimeAgo } from "../../../utils/datetime-format";
20
20
  import { truncateToDisplayWidth } from "../../../utils/format";
21
- import { isPlainKey } from "../../../utils/keyboard";
22
21
  import { ProWall, SignInWall } from "../cloud/auth-actions";
23
22
  import { useCloudPlanAction, useCloudUpgradeAction } from "../shared/cloud-upgrade";
24
23
  import { usePlanAccess } from "../../../api-client/plan-access";
25
24
  import { useBoundTicker } from "../shared/ticker-request";
26
25
 
27
26
  const FOOTER_ID = "equity-diagnostic";
28
- const REFRESH_SCOPE = "equity-diagnostic:refresh";
29
27
  /** Width of the "Observed"/"Reading" label column before the layout stacks. */
30
28
  const LABEL_WIDTH = 10;
31
29
  const STACK_BELOW_WIDTH = 44;
@@ -494,12 +492,7 @@ export function EquityDiagnosticView({ focused, width, height }: {
494
492
  const refresh = useCallback(() => load("refresh"), [load]);
495
493
  const retry = useCallback(() => load("cache-first"), [load]);
496
494
 
497
- useShortcut((event) => {
498
- if (!isPlainKey(event, "r")) return;
499
- event.preventDefault();
500
- event.stopPropagation();
501
- refresh();
502
- }, { enabled: focused && canRefresh && !loading, scope: REFRESH_SCOPE });
495
+ usePaneRefreshKey(refresh, { focused, enabled: canRefresh && !loading });
503
496
 
504
497
  // The report's citations are links a keyboard cannot focus, so `o` opens
505
498
  // them: straight away for one, through a chooser for several.
@@ -14,6 +14,7 @@ import {
14
14
  formatPercentRaw,
15
15
  } from "../../../utils/format";
16
16
  import { computeTTM } from "../ticker-detail/financials/aggregation";
17
+ import { declaredDividends } from "../../../utils/declared-dividends";
17
18
  import {
18
19
  earningsDateStatus,
19
20
  earningsEpsBasis,
@@ -398,6 +399,19 @@ export function buildEventRows(
398
399
  });
399
400
  }
400
401
 
402
+ // Declared and unpaid: its own row at its ex-date, apart from the paid distributions above.
403
+ for (const dividend of declaredDividends(data)) {
404
+ rows.push({
405
+ id: `div-declared:${dividend.exDate}`,
406
+ date: dividend.exDate,
407
+ status: "Dividend",
408
+ period: "-",
409
+ detail: dividend.payDate ? `Declared, payable ${dividend.payDate}` : "Declared",
410
+ value: formatDistributionAmount(dividend.amount, data?.currency ?? currency),
411
+ tone: "positive",
412
+ });
413
+ }
414
+
401
415
  for (const split of data?.splits ?? []) {
402
416
  rows.push({
403
417
  id: `split:${split.date}:${split.description ?? ""}`,
@@ -1,11 +1,41 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/headless";
2
+ import { EXTRA_COLUMN_IDS, normalizeExtraColumns } from "./relative-valuation-columns";
3
+ import { normalizeCapCurrency } from "./relative-valuation-currency";
2
4
 
3
5
  type PaneSchema = Pick<HeadlessPaneDefinition, "argument" | "options" | "discovery">;
4
6
 
5
7
  export const paneSchemas = {
6
8
  "relative-valuation-pane": {
7
9
  argument: { kind: "tickers" },
8
- options: [],
10
+ options: [
11
+ {
12
+ key: "peers",
13
+ type: "boolean",
14
+ settingKey: "autoPeers",
15
+ description: "With one symbol, add 4 to 6 competitors: the covered equities in its industry closest in market cap (an education company filed under software is compared with education companies, and an online learning company with its curated core first), or a curated list when too few are covered. The header names the method",
16
+ example: "--peers",
17
+ },
18
+ {
19
+ key: "columns",
20
+ type: "string",
21
+ settingKey: "extraColumns",
22
+ placeholder: "fields",
23
+ description: `Report columns to add, in order: ${EXTRA_COLUMN_IDS.join(", ")}. shortInterest is FINRA short interest as a percent of float at the latest settlement, `
24
+ + "nextEarnings the next report date on the earnings calendar, target the mean analyst target and the analyst count, grossMargin and evEbitda the screener's stored figures. "
25
+ + "Short interest, earnings dates, gross margin and EV/EBITDA cover US listings. CSV and JSON give each cell's source and as-of. The pane keeps its own columns",
26
+ example: "--columns shortInterest,nextEarnings,target,grossMargin,evEbitda",
27
+ normalize: normalizeExtraColumns,
28
+ },
29
+ {
30
+ key: "currency",
31
+ type: "string",
32
+ settingKey: "capCurrency",
33
+ placeholder: "ISO code",
34
+ description: "Show every market cap in this currency, converted at the app's FX rates; the status line names each rate and its time",
35
+ example: "--currency USD",
36
+ normalize: normalizeCapCurrency,
37
+ },
38
+ ],
9
39
  discovery: {
10
40
  id: "relative-valuation",
11
41
  aliases: ["relative valuation", "peer valuation", "valuation comps", "compare multiples"],
@@ -0,0 +1,289 @@
1
+ import type { ScreenDefinition, ScreenMetric, ScreenRow } from "../../../api-client/equity-screener";
2
+ import type { HeadlessPaneColumn, HeadlessPaneContext, HeadlessPaneRow } 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 { formatNumber } from "../../../utils/format";
7
+ import { fetchScreen } from "../equity-screener/client";
8
+ import { refusedLock } from "../shared/cloud-denied";
9
+ import { resolveHeadlessInstrument } from "../shared/headless-market-data";
10
+ import { loadShortInterest } from "../short-interest/client";
11
+ import { buildShortWatchRow } from "../short-interest/watch-model";
12
+ import { analystTargetCurrency, formatAnalystPrice, recommendationTotal } from "./analyst-model";
13
+
14
+ /** The columns `--columns` adds to RV's report, each read from the path its own function uses. */
15
+ export const EXTRA_COLUMN_IDS = ["shortInterest", "nextEarnings", "target", "grossMargin", "evEbitda"] as const;
16
+ export type ExtraColumnId = (typeof EXTRA_COLUMN_IDS)[number];
17
+
18
+ const COLUMN_ALIASES: Record<string, ExtraColumnId> = {
19
+ shortinterest: "shortInterest", short: "shortInterest", si: "shortInterest", shortfloat: "shortInterest",
20
+ nextearnings: "nextEarnings", earnings: "nextEarnings", earningsdate: "nextEarnings",
21
+ target: "target", targets: "target", analysttarget: "target", analysts: "target", pricetarget: "target",
22
+ grossmargin: "grossMargin", gross: "grossMargin", gm: "grossMargin",
23
+ evebitda: "evEbitda", evtoebitda: "evEbitda",
24
+ };
25
+
26
+ /** `--columns`: a comma list of the fields above (or their short names), in order, as the canonical names. */
27
+ export function normalizeExtraColumns(value: string): string {
28
+ const tokens = value.split(/[,\s]+/).filter(Boolean);
29
+ if (tokens.length === 0) throw new Error(`--columns needs one or more of: ${EXTRA_COLUMN_IDS.join(", ")}.`);
30
+ const ids = tokens.map((token) => {
31
+ const id = COLUMN_ALIASES[token.toLowerCase().replace(/[^a-z]/g, "")];
32
+ if (!id) throw new Error(`Unknown --columns field "${token}". Use any of: ${EXTRA_COLUMN_IDS.join(", ")}.`);
33
+ return id;
34
+ });
35
+ return [...new Set(ids)].join(",");
36
+ }
37
+
38
+ export function parseExtraColumns(value: unknown): ExtraColumnId[] {
39
+ return typeof value === "string" && value ? value.split(",").filter((id): id is ExtraColumnId => (EXTRA_COLUMN_IDS as readonly string[]).includes(id)) : [];
40
+ }
41
+
42
+ const percent = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)}%` : "-");
43
+ const text = (value: unknown) => (typeof value === "string" && value ? value : "-");
44
+
45
+ /** Each added column, then (exports only) the source and as-of of its cells. */
46
+ interface ColumnSpec {
47
+ columns: HeadlessPaneColumn[];
48
+ sourceKey: string;
49
+ asOfKey: string;
50
+ label: string;
51
+ }
52
+
53
+ const COLUMN_SPECS: Record<ExtraColumnId, ColumnSpec> = {
54
+ shortInterest: {
55
+ label: "Short % Float",
56
+ columns: [{ key: "shortInterestPercentFloat", header: "Short % Float", align: "right", format: percent }],
57
+ sourceKey: "shortInterestSource",
58
+ asOfKey: "shortInterestAsOf",
59
+ },
60
+ nextEarnings: {
61
+ label: "Next Earnings",
62
+ columns: [{
63
+ key: "nextEarningsDate", header: "Next Earnings", format: text, shrink: false,
64
+ // The text table says when in the day and whether the date is confirmed; exports keep them as columns.
65
+ textFormat: (value, row) => (typeof value === "string" && value
66
+ ? [value, row.nextEarningsTiming, row.nextEarningsStatus === "estimated" ? "est" : null].filter(Boolean).join(" ") : "-"),
67
+ }, { key: "nextEarningsTiming", header: "Next Earnings Timing", format: text }, { key: "nextEarningsStatus", header: "Next Earnings Status", format: text }],
68
+ sourceKey: "nextEarningsSource",
69
+ asOfKey: "nextEarningsAsOf",
70
+ },
71
+ target: {
72
+ label: "Avg Target",
73
+ columns: [
74
+ { key: "targetMean", header: "Avg Target", align: "right", unitKey: "targetCurrency",
75
+ format: (value, row) => (typeof value === "number" ? formatAnalystPrice(value, typeof row.targetCurrency === "string" ? row.targetCurrency : undefined) : "-") },
76
+ { key: "analystCount", header: "Analysts", align: "right", format: (value) => (typeof value === "number" ? String(value) : "-") },
77
+ ],
78
+ sourceKey: "targetSource",
79
+ asOfKey: "targetAsOf",
80
+ },
81
+ grossMargin: {
82
+ label: "Gross Margin",
83
+ columns: [{ key: "grossMarginPercent", header: "Gross Margin", align: "right", format: percent }],
84
+ sourceKey: "grossMarginSource",
85
+ asOfKey: "grossMarginAsOf",
86
+ },
87
+ evEbitda: {
88
+ label: "EV/EBITDA",
89
+ columns: [{ key: "evEbitda", header: "EV/EBITDA", align: "right", format: (value) => (typeof value === "number" ? formatNumber(value, 1) : "-") }],
90
+ sourceKey: "evEbitdaSource",
91
+ asOfKey: "evEbitdaAsOf",
92
+ },
93
+ };
94
+
95
+ /** The timing and status columns of Next Earnings stay out of the text table, which folds them into the date. */
96
+ const EXPORT_ONLY_KEYS = new Set(["nextEarningsTiming", "nextEarningsStatus"]);
97
+
98
+ /** The columns the added fields print as: `text` for the terminal table, `export` with each cell's source and as-of. */
99
+ export function extraColumns(ids: readonly ExtraColumnId[]): { text: HeadlessPaneColumn[]; export: HeadlessPaneColumn[] } {
100
+ const specs = ids.map((id) => COLUMN_SPECS[id]);
101
+ return {
102
+ text: specs.flatMap((spec) => spec.columns.filter((column) => !EXPORT_ONLY_KEYS.has(column.key))),
103
+ export: specs.flatMap((spec) => [
104
+ ...spec.columns,
105
+ { key: spec.sourceKey, header: `${spec.label} source`, format: text },
106
+ { key: spec.asOfKey, header: `${spec.label} as of`, format: text, shrink: false },
107
+ ]),
108
+ };
109
+ }
110
+
111
+ /** Whether a symbol is a US listing, which FINRA short interest, the earnings calendar and the screener cover. */
112
+ function usListing(symbol: string): string | null {
113
+ const { symbol: bare, exchange } = parsePublicTickerKey(symbol);
114
+ return !exchange || isUsListingExchange(exchange) ? bare.toUpperCase() : null;
115
+ }
116
+
117
+ /** A failed read of one column, as the report says it: a Pro or sign-in wall in the words its lock note uses, else why. */
118
+ function failureMessage(label: string, error: unknown, symbol?: string): string {
119
+ const refused = refusedLock(error);
120
+ if (refused?.gate === "pro") return `${label} requires Gloom Pro.`;
121
+ if (refused) return `${label} needs a free Gloom sign-in.`;
122
+ return `${symbol ? `${symbol}: ` : ""}${label} unavailable: ${errorMessage(error)}`;
123
+ }
124
+
125
+ interface ExtraColumnsResult {
126
+ /** The added fields of each symbol's row, keyed by the symbol as given. */
127
+ fields: Map<string, HeadlessPaneRow>;
128
+ errors: string[];
129
+ notes: string[];
130
+ }
131
+
132
+ const SHORT_INTEREST_SOURCES: Record<string, string> = { finra: "FINRA", gloom: "Gloom Cloud" };
133
+
134
+ /** Percent of float at the latest settlement, as `SIW` computes it, dated by that settlement. */
135
+ async function loadShortInterestFields(symbols: readonly string[], financials: ReadonlyMap<string, TickerFinancials | null>, ctx: HeadlessPaneContext, out: ExtraColumnsResult) {
136
+ const label = COLUMN_SPECS.shortInterest.label;
137
+ await Promise.all(symbols.map(async (symbol) => {
138
+ const bare = usListing(symbol);
139
+ if (!bare) return;
140
+ try {
141
+ const result = await loadShortInterest(bare, ctx.apiClient);
142
+ const row = buildShortWatchRow({ symbol, records: result.records, floatShares: financials.get(symbol)?.fundamentals?.floatShares ?? null, history: null });
143
+ if (!row.settlementDate) return;
144
+ Object.assign(out.fields.get(symbol)!, {
145
+ shortInterestPercentFloat: row.percentFloat,
146
+ shortInterestSource: SHORT_INTEREST_SOURCES[result.source] ?? "FINRA",
147
+ shortInterestAsOf: row.settlementDate,
148
+ });
149
+ } catch (error) {
150
+ out.errors.push(failureMessage(label, error, symbol));
151
+ }
152
+ }));
153
+ }
154
+
155
+ /** The next report date on the earnings calendar `earnings` reads, dated by when the calendar was read. */
156
+ async function loadNextEarningsFields(symbols: readonly string[], ctx: HeadlessPaneContext, out: ExtraColumnsResult) {
157
+ const label = COLUMN_SPECS.nextEarnings.label;
158
+ const listed = symbols.flatMap((symbol) => {
159
+ const bare = usListing(symbol);
160
+ return bare ? [{ symbol, bare }] : [];
161
+ });
162
+ if (listed.length === 0) return;
163
+ if (!ctx.marketData.getEarningsCalendar) {
164
+ out.errors.push(`${label} unavailable: the market data source has no earnings calendar.`);
165
+ return;
166
+ }
167
+ try {
168
+ const events = await ctx.marketData.getEarningsCalendar(listed.map(({ bare }) => bare));
169
+ const readAt = new Date().toISOString();
170
+ const today = readAt.slice(0, 10);
171
+ for (const { symbol, bare } of listed) {
172
+ const next = events
173
+ .filter((event) => event.symbol.toUpperCase() === bare && event.epsActual == null && event.earningsDate.toISOString().slice(0, 10) >= today)
174
+ .sort((left, right) => left.earningsDate.getTime() - right.earningsDate.getTime())[0];
175
+ if (!next) continue;
176
+ Object.assign(out.fields.get(symbol)!, {
177
+ nextEarningsDate: next.earningsDate.toISOString().slice(0, 10),
178
+ nextEarningsTiming: next.timing || null,
179
+ nextEarningsStatus: next.isDateEstimate === true ? "estimated" : next.isDateEstimate === false ? "confirmed" : null,
180
+ nextEarningsSource: "Gloom Cloud earnings calendar",
181
+ nextEarningsAsOf: readAt,
182
+ });
183
+ }
184
+ } catch (error) {
185
+ out.errors.push(failureMessage(label, error));
186
+ }
187
+ }
188
+
189
+ /** The mean analyst target and the analysts behind the latest recommendation mix, as `analyst` reads them. */
190
+ async function loadTargetFields(symbols: readonly string[], ctx: HeadlessPaneContext, out: ExtraColumnsResult) {
191
+ const label = COLUMN_SPECS.target.label;
192
+ const read = ctx.marketData.getAnalystResearch?.bind(ctx.marketData);
193
+ if (!read) {
194
+ out.errors.push(`${label} unavailable: the market data source has no analyst research.`);
195
+ return;
196
+ }
197
+ await Promise.all(symbols.map(async (symbol) => {
198
+ try {
199
+ const { symbol: ticker, exchange } = await resolveHeadlessInstrument(ctx, symbol);
200
+ const data = await read(ticker, exchange ?? "");
201
+ const average = data.priceTarget?.average;
202
+ const count = recommendationTotal(data);
203
+ if (average == null && count == null) return;
204
+ Object.assign(out.fields.get(symbol)!, {
205
+ targetMean: average != null && Number.isFinite(average) ? average : null,
206
+ targetCurrency: analystTargetCurrency(data) ?? null,
207
+ analystCount: count,
208
+ targetSource: "Gloom Cloud analyst research",
209
+ targetAsOf: data.fetchedAt ?? new Date().toISOString(),
210
+ });
211
+ } catch (error) {
212
+ out.errors.push(failureMessage(label, error, symbol));
213
+ }
214
+ }));
215
+ }
216
+
217
+ function metricFields(metric: ScreenMetric | undefined): { value: number | null; source: string | null; asOf: string | null } | null {
218
+ if (!metric || metric.value == null || !Number.isFinite(metric.value) || metric.state === "unavailable") return null;
219
+ return { value: metric.value, source: metric.source || null, asOf: metric.asOf ?? metric.observedAt ?? null };
220
+ }
221
+
222
+ /** Gross margin and EV/EBITDA as the screener stores them, each with its own source and statement date. */
223
+ async function loadScreenerFields(ids: readonly ExtraColumnId[], symbols: readonly string[], ctx: HeadlessPaneContext, out: ExtraColumnsResult) {
224
+ const wanted = ids.filter((id) => id === "grossMargin" || id === "evEbitda");
225
+ const listed = symbols.flatMap((symbol) => {
226
+ const bare = usListing(symbol);
227
+ return bare ? [{ symbol, bare }] : [];
228
+ });
229
+ if (wanted.length === 0 || listed.length === 0) return;
230
+ const definition: ScreenDefinition = {
231
+ version: 1,
232
+ criteria: [{ field: "symbol", op: "in", value: [...new Set(listed.map(({ bare }) => bare))] }],
233
+ sort: { field: "symbol", direction: "asc" },
234
+ currency: null,
235
+ };
236
+ let rows: ScreenRow[];
237
+ try {
238
+ rows = (await fetchScreen(definition, null, ctx.signal, ctx.apiClient)).rows;
239
+ } catch (error) {
240
+ out.errors.push(failureMessage(wanted.map((id) => COLUMN_SPECS[id].label).join(" and "), error));
241
+ return;
242
+ }
243
+ for (const { symbol, bare } of listed) {
244
+ const row = rows.find((candidate) => candidate.symbol.toUpperCase() === bare && isUsListingExchange(candidate.exchange));
245
+ if (!row) continue;
246
+ const fields = out.fields.get(symbol)!;
247
+ if (wanted.includes("grossMargin")) {
248
+ const gross = metricFields(row.metrics.grossMarginPercent);
249
+ if (gross) Object.assign(fields, { grossMarginPercent: gross.value, grossMarginSource: gross.source, grossMarginAsOf: gross.asOf });
250
+ }
251
+ if (wanted.includes("evEbitda")) {
252
+ const multiple = metricFields(row.metrics.evToEbitda);
253
+ if (multiple) Object.assign(fields, { evEbitda: multiple.value, evEbitdaSource: multiple.source, evEbitdaAsOf: multiple.asOf });
254
+ }
255
+ }
256
+ }
257
+
258
+ /** The US-only columns among `ids`, which a listing abroad leaves blank. */
259
+ const US_ONLY: ReadonlySet<ExtraColumnId> = new Set(["shortInterest", "nextEarnings", "grossMargin", "evEbitda"]);
260
+
261
+ /**
262
+ * The added fields for every symbol, each column read apart so one that
263
+ * fails (or is locked) leaves the others: short interest from FINRA's
264
+ * settlements, the next report date from the earnings calendar, the mean
265
+ * target and analyst count from analyst research, gross margin and EV/EBITDA
266
+ * from the screener's stored figures.
267
+ */
268
+ export async function loadExtraColumns(
269
+ ids: readonly ExtraColumnId[],
270
+ symbols: readonly string[],
271
+ financials: ReadonlyMap<string, TickerFinancials | null>,
272
+ ctx: HeadlessPaneContext,
273
+ ): Promise<ExtraColumnsResult> {
274
+ const out: ExtraColumnsResult = { fields: new Map(symbols.map((symbol) => [symbol, {}])), errors: [], notes: [] };
275
+ if (ids.length === 0) return out;
276
+ await Promise.all([
277
+ ids.includes("shortInterest") ? loadShortInterestFields(symbols, financials, ctx, out) : null,
278
+ ids.includes("nextEarnings") ? loadNextEarningsFields(symbols, ctx, out) : null,
279
+ ids.includes("target") ? loadTargetFields(symbols, ctx, out) : null,
280
+ loadScreenerFields(ids, symbols, ctx, out),
281
+ ]);
282
+ ctx.signal.throwIfAborted();
283
+ const abroad = symbols.filter((symbol) => !usListing(symbol));
284
+ const usOnly = ids.filter((id) => US_ONLY.has(id)).map((id) => COLUMN_SPECS[id].label);
285
+ if (abroad.length > 0 && usOnly.length > 0) {
286
+ out.notes.push(`${usOnly.join(", ")} cover US listings; ${abroad.join(", ")} ${abroad.length === 1 ? "is" : "are"} left blank.`);
287
+ }
288
+ return out;
289
+ }