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
@@ -5,6 +5,7 @@ import { findNamedCollection } from "../../portfolio/collection-name";
5
5
  import { apiClient } from "../../api-client";
6
6
  import type { MarketContext } from "../types";
7
7
  import type {
8
+ HeadlessArgumentExpansion,
8
9
  HeadlessBundleResult,
9
10
  HeadlessBundleSection,
10
11
  HeadlessPaneColumn,
@@ -37,6 +38,7 @@ import type { ResolvedSeries } from "../../time-series/types";
37
38
  import type { PaneFunctionReport } from "./report";
38
39
  import type { ResolvedPaneFunction } from "./resolver";
39
40
  import { isRecord } from "../../utils/guards";
41
+ import { splitSymbolArgs } from "../symbol-args";
40
42
  import { formatUtcTime, localTimeSuffix } from "../../utils/utc-time";
41
43
  import { deriveHeadlessFreshness, formatFreshnessLine, staleRowsText, type ReportFreshness } from "./freshness";
42
44
  import { accessGateStatus, errorsAccessGate, isProLock } from "./access-gate";
@@ -74,7 +76,7 @@ function normalizeSymbol(value: string): string {
74
76
  function normalizeSymbolList(value: string): string[] {
75
77
  const symbols: string[] = [];
76
78
  const seen = new Set<string>();
77
- for (const part of value.split(/[,\s]+/)) {
79
+ for (const part of splitSymbolArgs([value])) {
78
80
  const symbol = normalizeSymbol(part);
79
81
  if (!symbol || seen.has(symbol)) continue;
80
82
  seen.add(symbol);
@@ -162,22 +164,17 @@ async function loadHeadlessPaneModel(
162
164
  return result;
163
165
  }
164
166
 
165
- /**
166
- * Loads the same renderer-neutral model used by `fn`.
167
- * Both reports and screenshot payloads execute this loader.
168
- */
169
- export async function loadResolvedHeadlessPaneModel(
167
+ type HeadlessMarketContext = Pick<MarketContext, "config" | "store" | "refresh"> & Partial<Pick<MarketContext, "persistence" | "seriesCapabilities">> & {
168
+ dataProvider: HeadlessPaneContext["marketData"];
169
+ };
170
+
171
+ /** What a headless loader reads the market, the account and the saved collections through. */
172
+ function createHeadlessPaneContext(
170
173
  resolved: ResolvedPaneFunction,
171
- context: Pick<MarketContext, "config" | "store" | "refresh"> & Partial<Pick<MarketContext, "persistence" | "seriesCapabilities">> & {
172
- dataProvider: HeadlessPaneContext["marketData"];
173
- },
174
- rawArgument: string,
175
- signal: AbortSignal = new AbortController().signal,
176
- ): Promise<LoadedHeadlessPaneModel> {
177
- const definition = resolved.headless;
178
- if (!definition) throw new Error(`${resolved.token} has no headless pane model.`);
179
- const args = buildHeadlessPaneLoadArgs(definition, resolved.token, rawArgument, resolved.options);
180
- const headlessContext: HeadlessPaneContext = {
174
+ context: HeadlessMarketContext,
175
+ signal: AbortSignal,
176
+ ): HeadlessPaneContext {
177
+ return {
181
178
  marketData: context.dataProvider,
182
179
  apiClient,
183
180
  config: context.config,
@@ -203,7 +200,40 @@ export async function loadResolvedHeadlessPaneModel(
203
200
  return { symbol: parsed.symbol, exchange: parsed.exchange ?? ticker?.metadata.exchange };
204
201
  },
205
202
  };
206
- const result = await loadHeadlessPaneModel(definition, args, headlessContext);
203
+ }
204
+
205
+ /**
206
+ * The argument a function widens before it runs (`expandArgument`, RV's
207
+ * `--peers`), with the settings that say how; null when it runs as typed.
208
+ */
209
+ export async function expandResolvedArgument(
210
+ resolved: ResolvedPaneFunction,
211
+ context: HeadlessMarketContext,
212
+ rawArgument: string,
213
+ signal: AbortSignal = new AbortController().signal,
214
+ ): Promise<HeadlessArgumentExpansion | null> {
215
+ const definition = resolved.headless;
216
+ if (!definition?.expandArgument) return null;
217
+ const args = buildHeadlessPaneLoadArgs(definition, resolved.token, rawArgument, resolved.options);
218
+ const expansion = await definition.expandArgument(args, createHeadlessPaneContext(resolved, context, signal));
219
+ throwIfAborted(signal);
220
+ return expansion;
221
+ }
222
+
223
+ /**
224
+ * Loads the same renderer-neutral model used by `fn`.
225
+ * Both reports and screenshot payloads execute this loader.
226
+ */
227
+ export async function loadResolvedHeadlessPaneModel(
228
+ resolved: ResolvedPaneFunction,
229
+ context: HeadlessMarketContext,
230
+ rawArgument: string,
231
+ signal: AbortSignal = new AbortController().signal,
232
+ ): Promise<LoadedHeadlessPaneModel> {
233
+ const definition = resolved.headless;
234
+ if (!definition) throw new Error(`${resolved.token} has no headless pane model.`);
235
+ const args = buildHeadlessPaneLoadArgs(definition, resolved.token, rawArgument, resolved.options);
236
+ const result = await loadHeadlessPaneModel(definition, args, createHeadlessPaneContext(resolved, context, signal));
207
237
  return { definition, args, result };
208
238
  }
209
239
 
@@ -527,6 +557,36 @@ function splitErrors(result: HeadlessPaneResult): { locked: string[]; failed: st
527
557
  return { locked: errors.filter(isProLock), failed: errors.filter((error) => !isProLock(error)) };
528
558
  }
529
559
 
560
+ /**
561
+ * Why a report with nothing in it is empty, when its pane gave no reason of
562
+ * its own: the free view left it empty (the report carries a Pro line), or the
563
+ * symbols asked for are not covered by the function (or hold nothing for the
564
+ * options chosen). Null when something has
565
+ * rows, a table states its own reason, an error explains it, or no symbol was
566
+ * asked for (a board that is empty says nothing about coverage).
567
+ */
568
+ function emptyReportReason(
569
+ definition: HeadlessPaneDefinition,
570
+ result: HeadlessPaneResult,
571
+ args: HeadlessPaneLoadArgs,
572
+ token: string | undefined,
573
+ ): string | null {
574
+ if (resultRowCount(definition, result) > 0) return null;
575
+ const ownReason = definition.shape === "rows" ? !!(result as HeadlessRowsResult).empty
576
+ : definition.shape === "bundle" && (result as HeadlessBundleResult).sections.some((section) => "empty" in section && !!section.empty);
577
+ const { locked, failed } = splitErrors(result);
578
+ if (ownReason || failed.length) return null;
579
+ if (locked.length) return "Locked: Gloom Pro";
580
+ const symbols = result.symbols ?? args.symbols;
581
+ if (!symbols.length) return null;
582
+ // An option the reader chose (a range, a tab) can leave a covered symbol empty, so coverage is not claimed then.
583
+ const narrowed = definition.options.some((option) => {
584
+ const value = args.options[option.key];
585
+ return value !== undefined && value !== "" && String(value) !== String(option.defaultValue ?? "");
586
+ });
587
+ return narrowed ? `No data for ${symbols.join(", ")} with these options.` : `No data for ${symbols.join(", ")}: not covered${token ? ` by ${token}` : ""}.`;
588
+ }
589
+
530
590
  function textNotices(result: HeadlessPaneResult): string[] {
531
591
  const notices = result.metadata?.notices;
532
592
  if (!Array.isArray(notices)) return [];
@@ -634,15 +694,20 @@ export function renderHeadlessPaneText(
634
694
  args: HeadlessPaneLoadArgs,
635
695
  fallbackTitle: string,
636
696
  freshness: ReportFreshness = deriveHeadlessFreshness(definition, result),
697
+ /** The function's token, which an empty report names as the one that does not cover the symbol. */
698
+ token?: string,
637
699
  ): string {
638
700
  // A long title wraps to a narrow --width like the text under a table; each line stays bold.
639
701
  const lines = [...wrapToTerminal(reportTitle(definition, args, result, fallbackTitle)).split("\n").map(cliStyles.bold), ""];
640
702
  const notices = textNotices(result);
641
703
  if (notices.length) lines.push(...notices, "");
642
- switch (definition.shape) {
704
+ const reason = emptyReportReason(definition, result, args, token);
705
+ // Said once, not under the heading of each empty table.
706
+ if (reason) lines.push(reason);
707
+ else switch (definition.shape) {
643
708
  case "rows": {
644
709
  const rowsResult = result as HeadlessRowsResult;
645
- lines.push(renderRows(rowsResult.rows, rowsResult.columns, definition.columns, rowsResult.empty));
710
+ lines.push(renderRows(rowsResult.rows, rowsResult.textColumns ?? rowsResult.columns, definition.columns, rowsResult.empty));
646
711
  break;
647
712
  }
648
713
  case "bundle":
@@ -756,6 +821,7 @@ export async function buildHeadlessFunctionReport(
756
821
  loaded.args,
757
822
  resolved.label,
758
823
  freshness,
824
+ resolved.token,
759
825
  ),
760
826
  tables: headlessReportTables(
761
827
  loaded.definition,
@@ -1,5 +1,5 @@
1
1
  import { extname, isAbsolute, relative, resolve } from "path";
2
- import type { CliCommandContext } from "../../types/plugin";
2
+ import type { CliCommandContext, HeadlessArgumentExpansion } from "../../types/plugin";
3
3
  import type { MarketContext } from "../types";
4
4
  import { withMarketData } from "../context";
5
5
  import { cliStyles, renderStats, type CliStatEntry } from "../../utils/cli-output";
@@ -8,6 +8,7 @@ import {
8
8
  findFunctionCode,
9
9
  renderPaneCatalogReport,
10
10
  selectPaneCatalogMatches,
11
+ suggestPaneFunctions,
11
12
  type PaneFunctionCatalog,
12
13
  } from "./catalog";
13
14
  import { createPaneCatalog } from "./discovery";
@@ -19,6 +20,7 @@ import {
19
20
  } from "./options";
20
21
  import { applyListingArgument, resolvePaneFunction, UnknownPaneFunctionError, type ResolvedPaneFunction } from "./resolver";
21
22
  import { buildFunctionReport } from "./report";
23
+ import { expandResolvedArgument } from "./headless";
22
24
  import { defaultScreenshotPath, renderDesktopShot, shotSizeWarnings, shotStatusIssues } from "./screenshot";
23
25
  import {
24
26
  buildPaneCatalogEntries,
@@ -29,6 +31,7 @@ import { ListingArgError, listingReadNotes, loadForListing, quotedListingChoiceN
29
31
  import { knownGapMessage, settingsPointer, topicPointer } from "../known-gaps";
30
32
  import { selectReportTables } from "../report-tables";
31
33
  import { cliOutputWritten } from "../result";
34
+ import type { CatalogCommandHit } from "../unknown-command";
32
35
  import { CLI_EXIT_CODES } from "../exit-codes";
33
36
  import {
34
37
  GLOSSARY,
@@ -55,8 +58,14 @@ async function withPaneRuntime<T>(
55
58
  const context: MarketContext = ctx.cliOptions.refresh ? { ...market, refresh: true } : market;
56
59
  const registry = await createPaneCatalog(context, ctx.plugins);
57
60
  try {
58
- const parsed = await applyListingArgument(registry, context, parsePaneFunctionArgs(args, ctx.cliOptions), settings.command);
59
- const resolved = await resolvePaneFunction(registry, context, parsed, settings);
61
+ let parsed = await applyListingArgument(registry, context, parsePaneFunctionArgs(args, ctx.cliOptions), settings.command);
62
+ let resolved = await resolvePaneFunction(registry, context, parsed, settings);
63
+ // A function that widens its argument (RV's --peers) runs, reports and captures the widened one.
64
+ const expansion = await withPersistedCloudSession(context, () => expandResolvedArgument(resolved, context, parsed.arg));
65
+ if (expansion) {
66
+ parsed = { ...parsed, arg: expansion.argument };
67
+ resolved = withExpansion(await resolvePaneFunction(registry, context, parsed, settings), expansion);
68
+ }
60
69
  return await run({ parsed, context, registry, resolved });
61
70
  } finally {
62
71
  registry.destroy();
@@ -64,6 +73,15 @@ async function withPaneRuntime<T>(
64
73
  });
65
74
  }
66
75
 
76
+ /** The resolved function with what its expansion adds to the pane: settings, and a title that says how. */
77
+ function withExpansion(resolved: ResolvedPaneFunction, { settings = {}, title }: HeadlessArgumentExpansion): ResolvedPaneFunction {
78
+ return {
79
+ ...resolved,
80
+ optionSettings: { ...resolved.optionSettings, ...settings },
81
+ instance: { ...resolved.instance, ...(title ? { title } : {}), settings: { ...resolved.instance.settings, ...settings } },
82
+ };
83
+ }
84
+
67
85
  /** A word that can be a function code: letters and digits, with the dots and dashes some codes carry. */
68
86
  const FUNCTION_CODE_WORD = /^[\w.$-]{1,24}$/;
69
87
 
@@ -102,6 +120,42 @@ export async function functionCodeCommand(
102
120
  }
103
121
  }
104
122
 
123
+ /** How many functions an unknown command word is answered with. */
124
+ const COMMAND_HIT_LIMIT = 3;
125
+
126
+ /**
127
+ * The functions the catalog finds for a word typed as a command (`gloomberb
128
+ * comps`): what `catalog <word>` would list first, as many as are close.
129
+ * Empty when the word finds none or the catalog cannot be read.
130
+ */
131
+ export async function catalogCommandHits(ctx: CliCommandContext, token: string): Promise<CatalogCommandHit[]> {
132
+ const word = token.trim();
133
+ if (!/^[a-z][\w-]{1,30}$/i.test(word)) return [];
134
+ try {
135
+ return await withMarketData(ctx, async (market) => {
136
+ const registry = await createPaneCatalog(market, ctx.plugins);
137
+ try {
138
+ return (await suggestPaneFunctions(registry, market, word)).slice(0, COMMAND_HIT_LIMIT).map((entry) => ({
139
+ code: entry.token,
140
+ label: entry.label,
141
+ argKind: entry.argKind ?? null,
142
+ argPlaceholder: entry.argPlaceholder ?? null,
143
+ peers: entry.capability.options.some((option) => option.key === "peers" && option.type === "boolean"),
144
+ }));
145
+ } finally {
146
+ registry.destroy();
147
+ }
148
+ });
149
+ } catch {
150
+ return [];
151
+ }
152
+ }
153
+
154
+ /** Whether a list report has no row with data: it is empty, or every row is a name without any. */
155
+ function foundNothing(data: { rowCount: number; unavailableSymbols: readonly string[] }): boolean {
156
+ return data.rowCount === 0 || data.unavailableSymbols.length >= data.rowCount;
157
+ }
158
+
105
159
  export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
106
160
  await runPaneCliCommand(ctx, async () => {
107
161
  await withPaneRuntime(ctx, args, async ({ parsed, context, resolved }) => {
@@ -161,6 +215,8 @@ export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
161
215
  text: () => glossary ? `${report.text}\n\n${renderReportGlossary(resolved.token, glossary)}` : report.text,
162
216
  ...(tabular ? { tables: selectReportTables(report.tables, resolved.tableSection) } : {}),
163
217
  });
218
+ // The report is printed as it is; a list that found data for none of its names says so in the exit status too.
219
+ if (resolved.headless?.failsWithoutData && foundNothing(report.data)) process.exitCode = CLI_EXIT_CODES.error;
164
220
  }, { strictHeadlessOptions: true, tableSection: true });
165
221
  });
166
222
  }
@@ -283,7 +339,7 @@ function runGlossary(term: string, ctx: CliCommandContext): void {
283
339
  const { exact, partial } = lookupGlossary(term);
284
340
  const entries = [...exact, ...partial];
285
341
  if (entries.length === 0) {
286
- ctx.fail(`No glossary entry for "${term}".`, "gloomberb catalog glossary lists every term.");
342
+ ctx.fail(`No glossary entry for "${term}".`, knownGapMessage(term) ?? "gloomberb catalog glossary lists every term.");
287
343
  }
288
344
  ctx.printResult({ data: entries.map(glossaryRecord) }, { text: () => renderGlossaryEntries(entries).join("\n") });
289
345
  }
@@ -10,6 +10,7 @@ import {
10
10
  import { currentFxClosureStart } from "../../utils/fx-market-hours";
11
11
  import { zonedDateKey } from "../../utils/zoned-date-time";
12
12
  import type { ReportTime } from "../../utils/utc-time";
13
+ import type { QuoteIndexSession } from "../../types/financials";
13
14
 
14
15
  /** Where the venues behind a quote report stand, as a report says it after the delay. */
15
16
  type ReportMarketState = "open" | "pre-market" | "after-hours" | "closed";
@@ -27,6 +28,8 @@ export interface SessionObservation {
27
28
  exchange: string;
28
29
  marketState?: string;
29
30
  time: ReportTime | null;
31
+ /** The regular sessions an index quote carries, for a publisher the app has no calendar for. */
32
+ calendar?: QuoteIndexSession;
30
33
  }
31
34
 
32
35
  interface VenueSession {
@@ -35,16 +38,19 @@ interface VenueSession {
35
38
  /** The latest regular session that has closed: its local date and open. */
36
39
  last?: { date: string; open: number | null };
37
40
  timeZone?: string;
41
+ /** The sessions are the quote's own, not a calendar of the venue. */
42
+ quoted?: true;
38
43
  }
39
44
 
40
45
  const CLOSED_STATES = new Set(["CLOSED", "PREPRE", "POSTPOST"]);
46
+ const US_EXCHANGE_TIME_ZONE = "America/New_York";
41
47
 
42
48
  /**
43
49
  * A venue's session now, from its calendar (weekends and published holidays),
44
50
  * or from the state its source reports when the app has no calendar for it,
45
51
  * such as an index publisher or a futures exchange. Null when neither says.
46
52
  */
47
- function venueSession(exchange: string, marketState: string | undefined, now: number): VenueSession | null {
53
+ function venueSession(exchange: string, marketState: string | undefined, now: number, calendar?: QuoteIndexSession): VenueSession | null {
48
54
  if (isAlwaysOpenExchange(exchange)) return { state: "always" };
49
55
  const next = nextRegularSessionOpen(exchange, now);
50
56
  if (next) {
@@ -61,10 +67,24 @@ function venueSession(exchange: string, marketState: string | undefined, now: nu
61
67
  return { state: "closed", next, last, timeZone };
62
68
  }
63
69
  if (marketState === "REGULAR") return { state: "open" };
64
- if (marketState === "PRE") return { state: "pre-market" };
65
- if (marketState === "POST") return { state: "after-hours" };
66
- if (marketState && CLOSED_STATES.has(marketState)) return { state: "closed" };
67
- return null;
70
+ const stated = marketState === "PRE" ? "pre-market" : marketState === "POST" ? "after-hours" : marketState && CLOSED_STATES.has(marketState) ? "closed" : null;
71
+ if (!stated) return null;
72
+ return calendar ? { state: stated, ...quotedSessions(calendar, now) } : { state: stated };
73
+ }
74
+
75
+ /**
76
+ * An index publisher's sessions (SNP, DJI, NASDAQ GIDS) as its quote dates
77
+ * them, read like a venue's calendar: the open still to come, and the latest
78
+ * regular session that has closed.
79
+ */
80
+ function quotedSessions({ timeZone, current, regular }: QuoteIndexSession, now: number): Pick<VenueSession, "next" | "last" | "timeZone" | "quoted"> {
81
+ const closed = [...regular, current].filter((window) => window.end <= now).sort((left, right) => right.end - left.end)[0];
82
+ return {
83
+ timeZone,
84
+ quoted: true,
85
+ ...(current.start > now ? { next: { date: zonedDateKey(current.start, timeZone), open: current.start } } : {}),
86
+ ...(closed ? { last: { date: zonedDateKey(closed.start, timeZone), open: closed.start } } : {}),
87
+ };
68
88
  }
69
89
 
70
90
  /** The venue spot FX pairs trade on. It has no session calendar here; its week is in `fx-market-hours`. */
@@ -101,9 +121,52 @@ function closeDateOf(observation: SessionObservation, session: VenueSession, now
101
121
  function closeMarketOf(exchange: string, session: VenueSession): string | null {
102
122
  if (exchange === FX_VENUE) return "FX";
103
123
  if (!session.last || !session.timeZone) return null;
124
+ // An index publisher's name is no market: a session dated in New York is the US's, any other is a bare date.
125
+ if (session.quoted) return session.timeZone === US_EXCHANGE_TIME_ZONE ? "US" : null;
104
126
  return isUsListingExchange(exchange) ? "US" : exchange;
105
127
  }
106
128
 
129
+ /** One market's session behind a report that mixes markets: its local date, and whether that session has closed. */
130
+ export interface VenueSessionDate {
131
+ /** `US` for the US listing venues, which share one calendar, the venue's code elsewhere (`XETRA`, `EPA`). */
132
+ market: string;
133
+ /** The session's local date, `YYYY-MM-DD`. */
134
+ date: string;
135
+ /** The newest row on that market is its latest close. */
136
+ close: boolean;
137
+ }
138
+
139
+ /**
140
+ * Each market's session, for a report whose rows trade on more than one
141
+ * (a US line beside Xetra and Paris ones): the newest row of each, dated in
142
+ * its venue's own day, in the order the markets first appear. Empty when the
143
+ * rows share one market, or fewer than two can be placed.
144
+ */
145
+ export function reportVenueSessionDates(observations: readonly SessionObservation[], now: number): VenueSessionDate[] {
146
+ const newestByMarket = new Map<string, { observation: SessionObservation & { time: ReportTime }; session: VenueSession }>();
147
+ for (const observation of observations) {
148
+ const exchange = canonicalExchange(observation.exchange);
149
+ const time = observation.time;
150
+ if (!exchange || !time) continue;
151
+ const session = venueSession(exchange, observation.marketState, now);
152
+ if (!session || session.state === "always") continue;
153
+ const market = exchange === FX_VENUE ? "FX" : isUsListingExchange(exchange) ? "US" : exchange;
154
+ const current = newestByMarket.get(market);
155
+ if (!current || time.time > current.observation.time.time) newestByMarket.set(market, { observation: { ...observation, time }, session });
156
+ }
157
+ if (newestByMarket.size < 2) return [];
158
+ return [...newestByMarket].map(([market, { observation, session }]) => {
159
+ // A venue in its session whose newest row is from an earlier day has not printed today: that row is its last close.
160
+ const exchange = canonicalExchange(observation.exchange);
161
+ const latestClose = session.state === "open" && session.timeZone ? latestRegularSessionClose(exchange, now) : null;
162
+ const last = latestClose ? { date: latestClose.date, open: latestRegularSessionOpen(exchange, latestClose.close) } : session.last;
163
+ const closeDate = closeDateOf(observation, { ...session, last }, now);
164
+ const date = closeDate
165
+ ?? (session.timeZone ? zonedDateKey(observation.time.time, session.timeZone) : new Date(observation.time.time).toISOString().slice(0, 10));
166
+ return { market, date, close: closeDate != null };
167
+ });
168
+ }
169
+
107
170
  /**
108
171
  * Where the markets behind a quote report stand now, and whether the newest
109
172
  * observation is a close, and whose. Null market when the venues disagree
@@ -120,7 +183,7 @@ export function reportMarketSession(
120
183
  const exchange = canonicalExchange(observation.exchange);
121
184
  if (!exchange) continue;
122
185
  const key = `${exchange}:${observation.marketState ?? ""}`;
123
- const session = sessions.get(key) ?? venueSession(exchange, observation.marketState, now);
186
+ const session = sessions.get(key) ?? venueSession(exchange, observation.marketState, now, observation.calendar);
124
187
  if (!session) continue;
125
188
  sessions.set(key, session);
126
189
  if (observation.time && (!newest || observation.time.time > (newest.observation.time?.time ?? -Infinity))) {
@@ -12,6 +12,7 @@ import { normalizeTickerInput } from "../../tickers/search";
12
12
  import { slugifyName } from "../../utils/slugify";
13
13
  import { ListingArgError, listingReadNotes, resolveCliListing, usListingsOnlyMessage } from "../listing-arg";
14
14
  import { listingAbroadVenue } from "../../utils/sec";
15
+ import { splitSymbolArgs, stripStrayEdges } from "../symbol-args";
15
16
  import type { MarketContext } from "../types";
16
17
  import {
17
18
  buildPaneFunctionLookup,
@@ -135,10 +136,11 @@ export async function applyListingArgument(
135
136
  }
136
137
  const exchange = typeof args.options.exchange === "string" ? args.options.exchange : undefined;
137
138
  const { exchange: _exchange, ...options } = args.options;
138
- const typed = takesOneTicker ? [args.arg] : args.arg.split(/[,\s]+/).filter(Boolean);
139
+ const typed = takesOneTicker ? [args.arg] : splitSymbolArgs([args.arg]);
139
140
  if (typed.length === 0) return args;
140
141
  let listings = await Promise.all(typed.map((raw) => resolveCliListing(raw, exchange, context, { ownExchangeWins: typed.length > 1 })));
141
- const notes: string[] = [];
142
+ // A separator stuck to the end of a pasted list (`UPST;`) is not part of a symbol; it is named.
143
+ const notes: string[] = takesOneTicker ? [] : stripStrayEdges(args.arg).notes;
142
144
  const usOnly = headless?.usListingsOnly;
143
145
  const abroad = usOnly ? listings.filter((listing) => listingAbroadVenue(listing.key) != null) : [];
144
146
  if (usOnly && abroad.length > 0) {
@@ -936,6 +936,8 @@ export async function renderDesktopShot({
936
936
  watermark,
937
937
  statusLine = false,
938
938
  fitRows = false,
939
+ fitReader = false,
940
+ maxHeightCells,
939
941
  options,
940
942
  captureImage = true,
941
943
  onStalledReport,
@@ -953,6 +955,10 @@ export async function renderDesktopShot({
953
955
  statusLine?: boolean;
954
956
  /** No `--height` was asked for: a pane that fits its rows (FUT) grows to show them all. */
955
957
  fitRows?: boolean;
958
+ /** A pane whose reader has a page open grows to show all of its text (a report's, which saves no image). */
959
+ fitReader?: boolean;
960
+ /** How tall a pane that fits its rows or its reader may grow, in cells, where the height limit of a saved image does not apply. */
961
+ maxHeightCells?: number;
956
962
  options: Record<string, string | true>;
957
963
  captureImage?: boolean;
958
964
  /**
@@ -999,7 +1005,14 @@ export async function renderDesktopShot({
999
1005
  loadModel,
1000
1006
  );
1001
1007
  if (statusLine && captureImage) payload.statusLine = true;
1002
- if (fitRows && resolved.pane.shotFitsRows) payload.maxHeightCells = shotMaxHeightCells(scale ?? 1);
1008
+ const fitRoles = [
1009
+ ...(fitRows && resolved.pane.shotFitsRows ? ["data-table-body-scroll"] : []),
1010
+ ...(fitReader && resolved.pane.shotFitsReader ? ["detail-scroll-body"] : []),
1011
+ ];
1012
+ if (fitRoles.length > 0) {
1013
+ payload.maxHeightCells = maxHeightCells ?? shotMaxHeightCells(scale ?? 1);
1014
+ payload.fitRoles = fitRoles;
1015
+ }
1003
1016
  // Loads while the page renders.
1004
1017
  const report = payload.statusLine ? reportShotFreshness(resolved, loadModel) : undefined;
1005
1018
  // The same load says whether the report is complete, with or without a status line.
@@ -6,7 +6,11 @@ export interface ScrollBoxMetrics {
6
6
  scrollLeft: number;
7
7
  scrollWidth: number;
8
8
  clientWidth: number;
9
- /** The box draws a scrollbar on that axis, which says it overflows even when the measures cannot. */
9
+ /**
10
+ * The box draws a scrollbar on that axis, which says it overflows even when the measures cannot:
11
+ * a table's body, which draws only the rows near the viewport. A plain scroll box draws one by
12
+ * default and holds all its content, so its measures say it.
13
+ */
10
14
  scrollbarY: boolean;
11
15
  scrollbarX: boolean;
12
16
  }
@@ -1,11 +1,49 @@
1
+ /** The quote marks a pasted list wraps its entries in: "AAPL", 'MSFT', “NVDA”. */
2
+ const QUOTE_MARKS = "\"'`\u2018\u2019\u201C\u201D";
3
+ const QUOTE_EDGES = new RegExp(`^[${QUOTE_MARKS}]+|[${QUOTE_MARKS}]+$`, "g");
4
+
1
5
  /**
2
6
  * Symbols given as command arguments, each of which may hold several split by
3
7
  * commas, semicolons, spaces, tabs or newlines (a pasted list), with the blanks
4
- * dropped and the typed case kept. Every command that takes a symbol list splits
5
- * its arguments here, and `not-a-ticker` reads the typed spelling back from them.
8
+ * dropped, the quote marks around each entry removed and the typed case kept.
9
+ * Every command that takes a symbol list splits its arguments here, and
10
+ * `not-a-ticker` reads the typed spelling back from them.
6
11
  */
7
12
  export function splitSymbolArgs(args: readonly string[]): string[] {
8
- return args.flatMap((arg) => arg.replace(/^\uFEFF/, "").split(/[\s,;]+/)).filter(Boolean);
13
+ return args
14
+ .flatMap((arg) => arg.replace(/^\uFEFF/, "").split(/[\s,;]+/))
15
+ .map((entry) => entry.replace(QUOTE_EDGES, ""))
16
+ .filter(Boolean);
17
+ }
18
+
19
+ /** The list separators in `text`, each once: `,;` for a stray `;,;`. */
20
+ function separatorsIn(text: string): string {
21
+ return [...new Set([...text].filter((char) => char === "," || char === ";"))].join("");
22
+ }
23
+
24
+ /**
25
+ * A pasted list, or one cell of a file, without the separators and quote marks
26
+ * stuck to its ends (`UPST;`, `"GME",`). The separators it dropped are named
27
+ * (`Ignored the stray ";" after UPST.`) rather than swallowed, and never reach
28
+ * a listing search as part of a symbol, which would ask for `UPST;`.
29
+ */
30
+ export function stripStrayEdges(text: string): { text: string; notes: string[] } {
31
+ const junk = `[\\s,;${QUOTE_MARKS}]`;
32
+ const leading = text.match(new RegExp(`^${junk}+`))?.[0] ?? "";
33
+ const rest = text.slice(leading.length);
34
+ const trailing = rest.match(new RegExp(`${junk}+$`))?.[0] ?? "";
35
+ const inner = rest.slice(0, rest.length - trailing.length);
36
+ const entries = splitSymbolArgs([inner]);
37
+ const [first, last] = [entries[0], entries.at(-1)];
38
+ const before = separatorsIn(leading);
39
+ const after = separatorsIn(trailing);
40
+ const notes = !first
41
+ ? (before || after ? [`Ignored the stray "${separatorsIn(leading + trailing)}".`] : [])
42
+ : [
43
+ ...before ? [`Ignored the stray "${before}" before ${first}.`] : [],
44
+ ...after ? [`Ignored the stray "${after}" after ${last}.`] : [],
45
+ ];
46
+ return { text: inner, notes };
9
47
  }
10
48
 
11
49
  /** How a symbol is compared for repeats: AAPL, aapl and $AAPL are one. */
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import { fstatSync } from "fs";
3
- import { splitSymbolArgs, uniqueSymbols } from "./symbol-args";
3
+ import { splitSymbolArgs, stripStrayEdges, uniqueSymbols } from "./symbol-args";
4
4
 
5
5
  /** Header cells that name a spreadsheet's symbol column, compared without case, spaces or punctuation. */
6
6
  const SYMBOL_HEADERS = new Set(["symbol", "symbols", "ticker", "tickers", "code", "stockcode", "securitycode", "instrument"]);
@@ -129,13 +129,25 @@ export class SymbolListError extends Error {
129
129
  /**
130
130
  * The symbols a command was given: as arguments, from `--file`, and from
131
131
  * standard input when an argument is `-` or nothing else was given and input
132
- * is piped in. Repeats are dropped.
132
+ * is piped in. Repeats are dropped. A separator stuck to a symbol in a file, or
133
+ * left on the end of what was typed (`UPST;`), is dropped with the quote marks
134
+ * around it, and each is named in `strays` so the command can say so.
133
135
  */
134
- export async function readSymbolList(source: SymbolListSource): Promise<{ symbols: string[]; repeated: number }> {
136
+ export async function readSymbolList(source: SymbolListSource): Promise<{ symbols: string[]; repeated: number; strays: string[] }> {
135
137
  const readStdin = source.readStdin ?? (() => Bun.stdin.text());
136
138
  const wantsStdin = source.args.includes("-") || source.file === "-";
137
- const typed = splitSymbolArgs(source.args.filter((arg) => arg !== "-"));
139
+ const typedArgs = source.args.filter((arg) => arg !== "-");
140
+ const typed = splitSymbolArgs(typedArgs);
138
141
  const collected = [...typed];
142
+ // A separator left on the end of what was typed: `UPST;`, or a list that ends in a comma.
143
+ const strays = new Set<string>(stripStrayEdges(typedArgs.join(" ")).notes);
144
+ const addCells = (cells: readonly string[]) => {
145
+ for (const cell of cells) {
146
+ const cleaned = stripStrayEdges(cell);
147
+ cleaned.notes.forEach((note) => strays.add(note));
148
+ collected.push(cleaned.text);
149
+ }
150
+ };
139
151
  if (source.file != null && source.file !== "-") {
140
152
  if (!source.file.trim() || source.file.startsWith("--")) throw new SymbolListError("--file needs a path, such as --file symbols.csv.");
141
153
  let text: string;
@@ -145,10 +157,10 @@ export async function readSymbolList(source: SymbolListSource): Promise<{ symbol
145
157
  const code = (error as NodeJS.ErrnoException).code;
146
158
  throw new SymbolListError(code === "ENOENT" ? `No file at ${source.file}.` : `Could not read ${source.file}: ${error instanceof Error ? error.message : String(error)}`);
147
159
  }
148
- collected.push(...parseSymbolText(text));
160
+ addCells(parseSymbolText(text));
149
161
  }
150
162
  if (wantsStdin || (typed.length === 0 && source.file == null && (source.stdinPiped ?? stdinIsPiped()))) {
151
- collected.push(...parseSymbolText(await readStdin()));
163
+ addCells(parseSymbolText(await readStdin()));
152
164
  }
153
- return uniqueSymbols(collected);
165
+ return { ...uniqueSymbols(collected), strays: [...strays] };
154
166
  }