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
@@ -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
+ }
@@ -0,0 +1,164 @@
1
+ import type { HeadlessPaneContext } from "../../../types/headless";
2
+ import { MarketDataCoordinator, resolveEntryValue } from "../../../market-data/coordinator";
3
+ import { fxLegForCurrency } from "../../../market-data/coordinator/fx-legs";
4
+ import { resolveCurrencyUnit } from "../../../utils/currency-units";
5
+ import { errorMessage } from "../../../utils/errors";
6
+
7
+ /** One currency's US dollar rate, as the app's FX rates serve it: USD per unit, and when it was observed. */
8
+ export interface UsdRate {
9
+ rate: number;
10
+ /** The source's observation time; null when it states none. */
11
+ asOf: string | null;
12
+ /** When the rate was read, said in place of an observation time the source does not state. */
13
+ retrievedAt?: string | null;
14
+ }
15
+
16
+ /** A rate a market cap was converted at: `to` per `from`, and the older of its two legs' observation times. */
17
+ export interface CapConversion {
18
+ from: string;
19
+ to: string;
20
+ rate: number;
21
+ asOf: string | null;
22
+ /** When the rate was read, for a rate whose source states no observation time. */
23
+ retrievedAt: string | null;
24
+ }
25
+
26
+ type CapRow = { symbol: string; marketCap: number | null; marketCapCurrency: string | null };
27
+
28
+ export type ConvertedCapRow<Row extends CapRow> = Row & {
29
+ /** The cap as the source served it, and its currency, when it was converted. */
30
+ marketCapNative?: number;
31
+ marketCapNativeCurrency?: string;
32
+ };
33
+
34
+ const usable = (rate: number | undefined): rate is number => rate != null && Number.isFinite(rate) && rate > 0;
35
+
36
+ function olderOf(left: string | null, right: string | null): string | null {
37
+ if (!left || !right) return left ?? right;
38
+ return Date.parse(left) <= Date.parse(right) ? left : right;
39
+ }
40
+
41
+ /**
42
+ * Every row's market cap in `target`, through each currency's US dollar
43
+ * rate (a pence cap is first made pounds). A row already in `target`, or
44
+ * with no cap, stays as it is. A currency with no usable rate leaves its
45
+ * rows in their own currency and is listed in `unconverted`, so the table
46
+ * never mixes a guess in.
47
+ */
48
+ export function convertMarketCaps<Row extends CapRow>(
49
+ rows: readonly Row[],
50
+ target: string,
51
+ rates: ReadonlyMap<string, UsdRate>,
52
+ ): { rows: Array<ConvertedCapRow<Row>>; conversions: CapConversion[]; unconverted: Array<{ currency: string; symbols: string[] }> } {
53
+ const usd = (currency: string): UsdRate | undefined => (currency === "USD" ? { rate: 1, asOf: null } : rates.get(currency));
54
+ const conversions = new Map<string, CapConversion>();
55
+ const unconverted = new Map<string, string[]>();
56
+ const converted = rows.map((row): ConvertedCapRow<Row> => {
57
+ if (row.marketCap == null || !row.marketCapCurrency || row.marketCapCurrency === target) return row;
58
+ const unit = resolveCurrencyUnit(row.marketCapCurrency);
59
+ let rate = 1;
60
+ if (unit.currency !== target) {
61
+ const from = usd(unit.currency);
62
+ const to = usd(target);
63
+ if (!from || !to || !usable(from.rate) || !usable(to.rate)) {
64
+ unconverted.set(row.marketCapCurrency, [...unconverted.get(row.marketCapCurrency) ?? [], row.symbol]);
65
+ return row;
66
+ }
67
+ rate = from.rate / to.rate;
68
+ if (!conversions.has(unit.currency)) {
69
+ conversions.set(unit.currency, {
70
+ from: unit.currency, to: target, rate, asOf: olderOf(from.asOf, to.asOf), retrievedAt: olderOf(from.retrievedAt ?? null, to.retrievedAt ?? null),
71
+ });
72
+ }
73
+ }
74
+ return {
75
+ ...row,
76
+ marketCap: row.marketCap / unit.divisor * rate,
77
+ marketCapCurrency: target,
78
+ marketCapNative: row.marketCap,
79
+ marketCapNativeCurrency: row.marketCapCurrency,
80
+ };
81
+ });
82
+ return {
83
+ rows: converted,
84
+ conversions: [...conversions.values()],
85
+ unconverted: [...unconverted].map(([currency, symbols]) => ({ currency, symbols })),
86
+ };
87
+ }
88
+
89
+ /** A rate as a dealer quotes it: `EUR/USD 1.1612`, `USD/JPY 151.20`; a cross of two other currencies as `from/to`. */
90
+ export function conversionPairText({ from, to, rate }: Pick<CapConversion, "from" | "to" | "rate">): string {
91
+ const figure = (value: number) => value.toPrecision(5);
92
+ if (from !== "USD" && to !== "USD") return `${from}/${to} ${figure(rate)}`;
93
+ const other = to === "USD" ? from : to;
94
+ const usdPerOther = to === "USD" ? rate : 1 / rate;
95
+ const perDollar = fxLegForCurrency(other)?.invert ?? true;
96
+ return perDollar ? `USD/${other} ${figure(1 / usdPerOther)}` : `${other}/USD ${figure(usdPerOther)}`;
97
+ }
98
+
99
+ /** `2026-10-13 21:00 UTC`, or `2026-10-13` for a date. */
100
+ function rateTimeText(asOf: string): string {
101
+ const time = Date.parse(asOf);
102
+ if (!Number.isFinite(time)) return asOf;
103
+ return /^\d{4}-\d{2}-\d{2}$/.test(asOf) ? asOf : `${new Date(time).toISOString().slice(0, 16).replace("T", " ")} UTC`;
104
+ }
105
+
106
+ /**
107
+ * The status line's note naming each rate and its date:
108
+ * `market caps in USD at EUR/USD 1.1612 (2026-10-13 21:00 UTC)`.
109
+ */
110
+ export function conversionNote(target: string, conversions: readonly CapConversion[]): string | null {
111
+ if (conversions.length === 0) return null;
112
+ const rates = conversions.map((conversion) => `${conversionPairText(conversion)} (${conversion.asOf ? rateTimeText(conversion.asOf)
113
+ : conversion.retrievedAt ? `retrieved ${rateTimeText(conversion.retrievedAt)}` : "time not stated"})`);
114
+ return `market caps in ${target} at ${rates.join(", ")}`;
115
+ }
116
+
117
+ /**
118
+ * The US dollar rate of each currency through the app's FX path (the market
119
+ * data coordinator's `loadFxRate`, which converted totals read), a pence code
120
+ * as its pound. A currency whose rate fails is listed in `failed` with why.
121
+ */
122
+ export async function loadFxLegs(
123
+ currencies: readonly string[],
124
+ ctx: Pick<HeadlessPaneContext, "marketData">,
125
+ ): Promise<{ legs: Map<string, UsdRate>; failed: Array<{ currency: string; reason: string }> }> {
126
+ const wanted = [...new Set(currencies.map((currency) => resolveCurrencyUnit(currency).currency).filter((currency) => currency && currency !== "USD"))];
127
+ const legs = new Map<string, UsdRate>();
128
+ const failed: Array<{ currency: string; reason: string }> = [];
129
+ if (wanted.length === 0) return { legs, failed };
130
+ const coordinator = new MarketDataCoordinator(ctx.marketData);
131
+ try {
132
+ await Promise.all(wanted.map(async (currency) => {
133
+ try {
134
+ const entry = await coordinator.loadFxRate(currency);
135
+ const rate = resolveEntryValue(entry);
136
+ if (!usable(rate ?? undefined)) throw new Error(entry.error?.message ?? "no usable rate");
137
+ const time = (value: number | null | undefined) => (value != null && Number.isFinite(value) ? new Date(value).toISOString() : null);
138
+ legs.set(currency, { rate: rate!, asOf: time(entry.asOf), retrievedAt: time(entry.fetchedAt) });
139
+ } catch (error) {
140
+ failed.push({ currency, reason: errorMessage(error) });
141
+ }
142
+ }));
143
+ } finally {
144
+ coordinator.destroy();
145
+ }
146
+ return { legs, failed };
147
+ }
148
+
149
+ const CURRENCY_CODE = /^[A-Z]{3}$/;
150
+
151
+ /** `--currency`: a three-letter code the runtime knows, upper-cased. */
152
+ export function normalizeCapCurrency(value: string): string {
153
+ const code = value.trim().toUpperCase();
154
+ let known: readonly string[] = [];
155
+ try {
156
+ known = Intl.supportedValuesOf("currency");
157
+ } catch {
158
+ // An older runtime lists none; the shape check stands alone.
159
+ }
160
+ if (!CURRENCY_CODE.test(code) || (known.length > 0 && !known.includes(code))) {
161
+ throw new Error(`Invalid --currency value "${value}". Use a three-letter currency code such as USD or EUR.`);
162
+ }
163
+ return code;
164
+ }
@@ -1,11 +1,16 @@
1
1
  import { formatPriceEarnings, NOT_MEANINGFUL, PRICE_EARNINGS_NOTICE } from "../../../utils/price-earnings";
2
- import type { HeadlessPaneColumn, HeadlessPaneDefinition } from "../../../types/headless";
2
+ import type { HeadlessPaneColumn, HeadlessPaneContext, HeadlessPaneDefinition } from "../../../types/headless";
3
3
  import { EXTENDED_SESSION_LABELS, type ExtendedSession } from "../../../market-data/market/status";
4
4
  import { formatCompact, formatCurrency, formatLevelPercent, formatNumber, formatPercent, formatPercentRaw } from "../../../utils/format";
5
5
  import { loadHeadlessFinancials, loadHeadlessSymbols } from "../shared/headless-market-data";
6
6
  import { quoteFreshnessFields } from "../shared/report-freshness";
7
7
  import { RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE, RELATIVE_VALUATION_STALE_QUOTE_NOTICE, relativeValuationValues } from "./relative-valuation-model";
8
8
  import { paneSchemas } from "./headless-schema";
9
+ import { isRecord } from "../../../utils/guards";
10
+ import { resolveCurrencyUnit } from "../../../utils/currency-units";
11
+ import { pickPeers, peerMethodTitle, type PeerSelection } from "./relative-valuation-peers";
12
+ import { extraColumns, loadExtraColumns, parseExtraColumns } from "./relative-valuation-columns";
13
+ import { conversionNote, convertMarketCaps, loadFxLegs } from "./relative-valuation-currency";
9
14
 
10
15
  const price = (value: unknown, row: Record<string, unknown>) => (
11
16
  value == null || !row.currency ? "-" : formatCurrency(Number(value), String(row.currency))
@@ -46,7 +51,9 @@ const VALUATION_COLUMNS: HeadlessPaneColumn[] = [
46
51
  })),
47
52
  ...[["evSales", "EV/S"]].map(([key, header]) => ({
48
53
  key: key!, header: header!, align: "right" as const,
49
- format: (value: unknown) => value == null ? "-" : formatNumber(Number(value), 1),
54
+ // A withdrawn EV says so rather than leaving a bare dash; the note under the table says why.
55
+ format: (value: unknown, row: Record<string, unknown>) => value != null ? formatNumber(Number(value), 1)
56
+ : row.evSalesUnavailable ? "EV unavailable" : "-",
50
57
  })),
51
58
  ...[["fcfYield", "FCF Yield"], ["revenueGrowth", "Quarter Revenue YoY"], ["operatingMargin", "TTM Op Margin"]].map(([key, header]) => ({
52
59
  key: key!, header: header!, align: "right" as const,
@@ -54,36 +61,112 @@ const VALUATION_COLUMNS: HeadlessPaneColumn[] = [
54
61
  })),
55
62
  ];
56
63
 
64
+ /** Peers `--peers` chose, as the expansion left them in the settings or the load picked them. */
65
+ function peerSelectionOf(value: unknown, symbols: readonly string[]): PeerSelection | null {
66
+ if (!isRecord(value) || typeof value.subject !== "string" || !Array.isArray(value.peers)) return null;
67
+ const selection = value as unknown as PeerSelection;
68
+ const expected = [selection.subject, ...selection.peers];
69
+ return expected.length === symbols.length && expected.every((symbol, index) => symbol.toUpperCase() === symbols[index]?.toUpperCase())
70
+ ? selection : null;
71
+ }
72
+
73
+ /** The peers of the one symbol `--peers` was given; several symbols are already a peer list. */
74
+ async function choosePeers(symbols: readonly string[], ctx: HeadlessPaneContext): Promise<PeerSelection> {
75
+ if (symbols.length !== 1) {
76
+ throw new Error(`--peers picks the peers of one symbol, such as RV ${symbols[0] ?? "DUOL"} --peers; with several symbols, leave it out.`);
77
+ }
78
+ const subject = symbols[0]!;
79
+ return pickPeers(subject, await loadHeadlessFinancials(ctx, subject).catch(() => null), ctx);
80
+ }
81
+
82
+ const PEER_SELECTION_SETTING = "peerSelection";
83
+
57
84
  export const relativeValuationHeadless: HeadlessPaneDefinition<"rows"> = {
58
85
  ...paneSchemas["relative-valuation-pane"],
86
+ freshness: { datesEachVenue: true },
59
87
  shape: "rows",
60
- describe: ({ symbols }) => `Relative Valuation | ${symbols.join(", ")}`,
88
+ describe: ({ symbols }, result) => {
89
+ const selection = peerSelectionOf(result?.metadata?.peerSelection, result?.symbols ?? symbols);
90
+ return selection
91
+ ? `Relative Valuation | ${selection.subject} and ${peerMethodTitle(selection)}`
92
+ : `Relative Valuation | ${(result?.symbols ?? symbols).join(", ")}`;
93
+ },
61
94
  columns: [...QUOTE_COLUMNS, ...VALUATION_COLUMNS],
62
- async load({ symbols }, ctx) {
95
+ async expandArgument(args, ctx) {
96
+ if (args.options.peers !== true) return null;
97
+ const selection = await choosePeers(args.symbols, ctx);
98
+ return {
99
+ argument: [selection.subject, ...selection.peers].join(", "),
100
+ settings: { [PEER_SELECTION_SETTING]: selection },
101
+ title: `RV ${selection.subject} + ${peerMethodTitle(selection, { automatic: true })}`,
102
+ };
103
+ },
104
+ async load(args, ctx) {
105
+ // --peers picked in advance (fn and shot) reads the same peers; any other caller picks them here.
106
+ const preset = args.options.peers === true ? peerSelectionOf(ctx.settings?.[PEER_SELECTION_SETTING], args.symbols) : null;
107
+ const selection = args.options.peers === true ? preset ?? await choosePeers(args.symbols, ctx) : null;
108
+ const symbols = selection ? [selection.subject, ...selection.peers] : args.symbols;
63
109
  const loaded = await loadHeadlessSymbols(symbols, ctx, (symbol) => loadHeadlessFinancials(ctx, symbol));
64
- const rows = loaded.entries.map(({ symbol, data }) => ({ symbol, ...relativeValuationValues(data), ...quoteFreshnessFields(data?.quote) }));
65
- const unavailableSymbols = [...loaded.unavailableSymbols, ...rows.filter((row) => ![
110
+ const baseRows = loaded.entries.map(({ symbol, data }) => ({ symbol, ...relativeValuationValues(data), ...quoteFreshnessFields(data?.quote) }));
111
+ const unavailableSymbols = [...loaded.unavailableSymbols, ...baseRows.filter((row) => ![
66
112
  row.marketCap, row.trailingPE, row.forwardPE, row.evSales, row.fcfYield, row.revenueGrowth, row.operatingMargin,
67
113
  ].some((value) => value != null)).map(({ symbol }) => symbol)];
68
- const staleSymbols = rows.filter((row) => row.quoteStale).map((row) => row.symbol);
69
- const staleFundamentalsSymbols = rows.filter((row) => row.fundamentalsProvenance?.stale).map((row) => row.symbol);
114
+ const staleSymbols = baseRows.filter((row) => row.quoteStale).map((row) => row.symbol);
115
+ const staleFundamentalsSymbols = baseRows.filter((row) => row.fundamentalsProvenance?.stale).map((row) => row.symbol);
116
+
117
+ // --currency: every cap in one currency, at the rates the app converts with.
118
+ const capCurrency = typeof args.options.currency === "string" ? args.options.currency : null;
119
+ const fx = capCurrency ? await loadFxLegs([capCurrency, ...baseRows.flatMap((row) => row.marketCapCurrency ?? [])], ctx) : null;
120
+ const converted = capCurrency && fx ? convertMarketCaps(baseRows, capCurrency, fx.legs) : null;
121
+ const capRows = converted?.rows ?? baseRows;
122
+
123
+ // --columns: each added column read from the path its own function uses.
124
+ const extraIds = parseExtraColumns(args.options.columns);
125
+ const financials = new Map(loaded.entries.map(({ symbol, data }) => [symbol, data] as const));
126
+ const extra = await loadExtraColumns(extraIds, capRows.map((row) => row.symbol), financials, ctx);
127
+ const rows = capRows.map((row) => ({ ...row, ...extra.fields.get(row.symbol) }));
128
+
70
129
  const errors = [...loaded.errors,
71
130
  ...staleSymbols.map((symbol) => `${symbol}: ${RELATIVE_VALUATION_STALE_QUOTE_NOTICE}`),
72
131
  ...staleFundamentalsSymbols.map((symbol) => `${symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}`),
132
+ ...extra.errors,
133
+ ];
134
+ const warnings = converted?.unconverted.map(({ currency, symbols: left }) => {
135
+ const reason = fx?.failed.find((failure) => failure.currency === resolveCurrencyUnit(currency).currency)?.reason;
136
+ return `No ${currency} rate${reason ? ` (${reason})` : ""}: ${left.join(", ")} ${left.length === 1 ? "keeps its" : "keep their"} market cap in ${currency}.`;
137
+ }) ?? [];
138
+ const conversionText = capCurrency && converted ? conversionNote(capCurrency, converted.conversions) : null;
139
+ const notes = [
140
+ ...selection ? [`Peers chosen automatically: ${selection.basis}. To choose your own: gloomberb fn RV ${selection.subject} <tickers>.`] : [],
141
+ ...extra.notes,
73
142
  ];
74
143
  // Last and Chg% are the regular session; an extended print gets a column only when a peer has one.
75
144
  const extendedSessions = (["PRE", "POST", "OVERNIGHT"] as const).filter((session) => rows.some((row) => row.extendedSession === session));
145
+ const columns = [...QUOTE_COLUMNS, ...extendedSessions.map(extendedColumn), ...VALUATION_COLUMNS];
146
+ const added = extraIds.length > 0 ? extraColumns(extraIds) : null;
76
147
  return {
77
148
  rows, unavailableSymbols, errors, complete: unavailableSymbols.length === 0 && errors.length === 0,
78
- ...(extendedSessions.length > 0 ? { columns: [...QUOTE_COLUMNS, ...extendedSessions.map(extendedColumn), ...VALUATION_COLUMNS] } : {}),
149
+ ...(selection ? { symbols } : {}),
150
+ ...(added ? { columns: [...columns, ...added.export], textColumns: [...columns, ...added.text] }
151
+ : extendedSessions.length > 0 ? { columns } : {}),
152
+ ...(notes.length > 0 ? { notes } : {}),
153
+ ...(warnings.length > 0 ? { warnings } : {}),
154
+ ...(conversionText ? { freshness: { note: conversionText } } : {}),
79
155
  metadata: {
80
156
  staleSymbols,
81
157
  staleFundamentalsSymbols,
158
+ ...(selection ? { peerSelection: selection } : {}),
159
+ ...(converted && capCurrency ? { marketCapConversion: { currency: capCurrency, rates: converted.conversions,
160
+ basis: "marketCap is converted into the asked currency at each currency's US dollar rate, the rate the app's converted totals use; marketCapNative and marketCapNativeCurrency keep the cap as served." } } : {}),
161
+ ...(extraIds.length > 0 ? { addedColumnsBasis: "Each added column keeps its own source and as-of beside it: short interest at its FINRA settlement date, analyst targets at retrieval, the next earnings date at the calendar's retrieval, gross margin and EV/EBITDA at the screener's statement date." } : {}),
82
162
  quoteBasis: "Stale quote fields are excluded from comparison; reportedQuote retains the rejected observation and quoteAsOf retains its source timestamp.",
83
163
  priceBasis: "price, change and changePercent are the regular session: after the close, its close and that close's move. A pre-market, after-hours or overnight print is in extendedPrice, extendedChange and extendedChangePercent, measured from that close (extendedMark when it is a bid and ask midpoint). marketCap, the P/E multiples, evSales (through the equity part of the enterprise value) and fcfYield are measured at that regular session price.",
84
164
  fundamentalsBasis: "Provider multiples and operating metrics retain independent fundamentalsProvenance. Retrieval time does not establish the valuation date.",
85
- notices: rows.some((row) => Object.keys(row.notMeaningful).length > 0
86
- || Object.values(row.reportedMultiples).some((value) => value != null && value <= 0)) ? [PRICE_EARNINGS_NOTICE] : [],
165
+ notices: [
166
+ ...rows.some((row) => Object.keys(row.notMeaningful).length > 0
167
+ || Object.values(row.reportedMultiples).some((value) => value != null && value <= 0)) ? [PRICE_EARNINGS_NOTICE] : [],
168
+ ...rows.flatMap((row) => row.evSalesUnavailable ? [`${row.symbol}: ${row.evSalesUnavailable}`] : []),
169
+ ],
87
170
  multipleBasis: "Comparable P/E fields require a positive finite multiple over positive EPS; notMeaningful says why one is null, and reportedMultiples preserves the finite provider values.",
88
171
  marketCapBasis: "Market caps retain their own currency and source. Fundamentals retrieval time is not a valuation date; quote timestamps do not date fallback caps.",
89
172
  },
@@ -1,6 +1,6 @@
1
1
  import { comparablePriceEarnings, notMeaningfulMultiples } from "../../../utils/price-earnings";
2
2
  import { selectMarketCapitalization } from "../../../utils/market-capitalization";
3
- import { reportedEnterpriseValue } from "../../../utils/fundamentals";
3
+ import { enterpriseValueReconciliationNote, enterpriseValueWithdrawn, reportedEnterpriseValue } from "../../../utils/fundamentals";
4
4
  import { getExtendedSessionDisplay, getQuoteSessionFields } from "../../../market-data/market/status";
5
5
  import { atRegularSessionPrice, movePriceDerivedFigures } from "../../../market-data/quotes/regular-session-valuation";
6
6
  import type { Quote, TickerFinancials } from "../../../types/financials";
@@ -70,6 +70,9 @@ export function relativeValuationValues(snapshot: TickerFinancials | null) {
70
70
  ? fundamentals.enterpriseToRevenue
71
71
  : enterpriseValue != null && fundamentals?.revenue != null && fundamentals.revenue > 0
72
72
  ? enterpriseValue / fundamentals.revenue : null,
73
+ /** Why EV/S is missing when the provider's EV was withdrawn, as `valuation` says it; null otherwise. */
74
+ evSalesUnavailable: enterpriseValueWithdrawn(fundamentals)
75
+ ? enterpriseValueReconciliationNote(fundamentals) ?? "EV unavailable." : null,
73
76
  fcfYield: capitalization && fundamentals?.financialCurrency === capitalization.currency
74
77
  && fundamentals.freeCashFlow != null && Number.isFinite(fundamentals.freeCashFlow) && capitalization.value > 0
75
78
  ? fundamentals.freeCashFlow / capitalization.value : null,