gloomberb 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -1,5 +1,5 @@
1
1
  import type { DesktopPaneShotRenderedRow } from "../desktop-pane-shot";
2
- import { cliStyles, renderSection, renderTable } from "../../utils/cli-output";
2
+ import { cliStyles, cliTerminalWidth, renderSection, renderTable, wrapText } from "../../utils/cli-output";
3
3
  import { formatUtcTime } from "../../utils/utc-time";
4
4
  import type { MarketContext } from "../types";
5
5
  import type { PaneFunctionReport } from "./report";
@@ -14,10 +14,14 @@ import { renderedReportFailure, renderedReportFreshness, renderedReportNotices,
14
14
 
15
15
  const DOM_REPORT_WIDTH = 1280;
16
16
  const DOM_REPORT_HEIGHT = 720;
17
+ /** A pane whose reader has a page open grows to show all of it, up to about 18,000 px: a report saves no image to bound it. */
18
+ const DOM_REPORT_MAX_HEIGHT_CELLS = 1000;
17
19
  const DOM_LIMITATION =
18
20
  "Only values visible in the rendered pane are returned; clipped or off-screen data may be omitted.";
19
21
  const DOM_EVERY_ROW =
20
22
  "Every row of the pane's table is returned, including rows scrolled out of view.";
23
+ const DOM_WHOLE_PANE =
24
+ "The pane was grown to fit the page it has open, so all of its text is returned.";
21
25
 
22
26
  /** The sign-in wall's buttons, which a rendered view reads as words: `Log in ↵ Sign up free`. */
23
27
  const SIGN_IN_WALL_BUTTONS = /\s*\b(?:Log in|Sign up free|Resend Verification Email)(?:\s*\u21B5)?(?=\s|$)/g;
@@ -39,6 +43,28 @@ function signInWallText(visibleText: string): string {
39
43
  return headline ? `${headline} ${needs}` : needs;
40
44
  }
41
45
 
46
+ const LIST_MARKER = /^\s*[\u2022\u25E6\u25AA\u00B7\u2013-]\s*$/;
47
+
48
+ /**
49
+ * The words of a pane that is prose, not a table, a line to each block it draws
50
+ * and wrapped to the terminal's width (100 where it has none): the pane's own title,
51
+ * which the report's heading says already, is left off its top.
52
+ */
53
+ function proseLines(render: PaneScreenshotResult["render"], title: string): string[] {
54
+ const lines = render.visibleLines?.length ? render.visibleLines : [render.visibleText];
55
+ const width = Math.min(cliTerminalWidth() ?? 100, 100);
56
+ const titled = lines[0]?.trim() === title ? lines.slice(1) : lines;
57
+ const body: string[] = [];
58
+ // A stack's detail opens under its Back button, a control and not part of the page.
59
+ for (const line of titled[0] === "Back" ? titled.slice(1) : titled) {
60
+ // A list marker is drawn as a block of its own, ahead of the item's words.
61
+ const marker = body.at(-1);
62
+ if (marker !== undefined && LIST_MARKER.test(marker)) body[body.length - 1] = `${marker.trim()} ${line}`;
63
+ else body.push(line);
64
+ }
65
+ return body.flatMap((line) => wrapText(line, width));
66
+ }
67
+
42
68
  function rowsContainEllipsis(rows: DesktopPaneShotRenderedRow[]): boolean {
43
69
  return rows.some((row) => row.cells.some((cell) => /\u2026|\.\.\./.test(cell.text)));
44
70
  }
@@ -215,7 +241,7 @@ export function buildDomPaneReportFromRender(
215
241
  } else if (lockNote) {
216
242
  textLines.push(...lockNoteLines(lockNote));
217
243
  } else if (result.render.visibleText) {
218
- textLines.push(gate === "sign-in" ? signInWallText(result.render.visibleText) : result.render.visibleText);
244
+ textLines.push(...(gate === "sign-in" ? [signInWallText(result.render.visibleText)] : proseLines(result.render, resolved.label)));
219
245
  } else {
220
246
  textLines.push(failureReason ?? "No rendered values were available.");
221
247
  }
@@ -231,6 +257,8 @@ export function buildDomPaneReportFromRender(
231
257
  const complete = failureReason === null && !truncated;
232
258
  // Each table published its rows and nothing else was cut: the report is the whole view.
233
259
  const everyRow = complete && result.render.reportRows !== undefined;
260
+ // A reader grown to fit its page (a docs page) and not cut once grown is read whole.
261
+ const wholePane = complete && !everyRow && resolved.pane.shotFitsReader === true;
234
262
  return {
235
263
  data: {
236
264
  kind: "rendered-view",
@@ -247,7 +275,8 @@ export function buildDomPaneReportFromRender(
247
275
  truncated,
248
276
  truncationReasons,
249
277
  ...(everyRow ? { everyRow } : {}),
250
- limitation: everyRow ? DOM_EVERY_ROW : DOM_LIMITATION,
278
+ ...(wholePane ? { wholePane } : {}),
279
+ limitation: everyRow ? DOM_EVERY_ROW : wholePane ? DOM_WHOLE_PANE : DOM_LIMITATION,
251
280
  ...(lockReason ?? failureReason ? { reason: lockReason ?? failureReason } : {}),
252
281
  ...(notices.length > 0 ? { metadata: { ...rendered.facts, notices } } : {}),
253
282
  ...(locked ? { locked } : { freshness }),
@@ -278,12 +307,12 @@ export function appendDomReportFooter(
278
307
  ].join("\n");
279
308
  }
280
309
 
281
- /** How much of the pane a rendered-view report holds: none behind a wall, cut, every row its tables publish, or what the viewport shows. */
282
- export type DomReportView = "locked" | "clipped" | "every-row" | "visible";
310
+ /** How much of the pane a rendered-view report holds: none behind a wall, cut, every row its tables publish, all of a pane grown to fit, or what the viewport shows. */
311
+ export type DomReportView = "locked" | "clipped" | "every-row" | "whole" | "visible";
283
312
 
284
313
  export function domReportView(data: Readonly<Record<string, unknown>>): DomReportView {
285
314
  if (data.locked) return "locked";
286
- return data.truncated === true ? "clipped" : data.everyRow === true ? "every-row" : "visible";
315
+ return data.truncated === true ? "clipped" : data.everyRow === true ? "every-row" : data.wholePane === true ? "whole" : "visible";
287
316
  }
288
317
 
289
318
  /** What a rendered-view report says about where its values come from. */
@@ -292,6 +321,8 @@ export function domReportNote(elapsedMs: number, view: DomReportView): string {
292
321
  ? "Rendered view: the pane is locked, so no values were read."
293
322
  : view === "every-row"
294
323
  ? "Rendered view: every row of the pane's table, including rows scrolled out of view."
324
+ : view === "whole"
325
+ ? "Rendered view: the pane grown to fit the whole page."
295
326
  : `Rendered view: values come from the visible pane. ${view === "clipped" ? "The rendered view is clipped." : "The rendered view may be clipped."}`;
296
327
  return `${source} Render time: ${elapsedMs} ms.`;
297
328
  }
@@ -356,6 +387,8 @@ export async function buildDomFunctionReport(
356
387
  theme: null,
357
388
  scale: 1,
358
389
  watermark: null,
390
+ fitReader: true,
391
+ maxHeightCells: DOM_REPORT_MAX_HEIGHT_CELLS,
359
392
  options: {},
360
393
  captureImage: false,
361
394
  });
@@ -10,8 +10,8 @@ import type {
10
10
  } from "../../types/plugin";
11
11
  import { localTimeSuffix, parseReportTime, type ReportTime } from "../../utils/utc-time";
12
12
  import { isRecord } from "../../utils/guards";
13
- import { latestRegularSessionClose } from "../../market-data/market/freshness";
14
- import { reportMarketSession, type ReportMarket, type SessionObservation } from "./market-session";
13
+ import { latestRegularSessionClose, readIndexSession } from "../../market-data/market/freshness";
14
+ import { reportMarketSession, reportVenueSessionDates, type ReportMarket, type SessionObservation, type VenueSessionDate } from "./market-session";
15
15
 
16
16
  /**
17
17
  * The four states of docs/usage.md#how-current-a-report-is, plus `unreported`
@@ -53,6 +53,12 @@ export interface ReportFreshness {
53
53
  tradingDayMarket?: string;
54
54
  /** Where the venues of a quote report stand now, from their session calendars. */
55
55
  market?: ReportMarket;
56
+ /**
57
+ * With a declared `datesEachVenue`, when the rows trade on more than one
58
+ * market: each market's session date, which the status line gives in place
59
+ * of the newest row's alone.
60
+ */
61
+ venueSessions?: VenueSessionDate[];
56
62
  /**
57
63
  * For a dataset (declared `dataset`, with no quote among its rows): whole UTC
58
64
  * days from the last day its newest observation covers to when this report
@@ -76,6 +82,8 @@ export interface ReportFreshness {
76
82
  oldestAgeMinutes?: number;
77
83
  /** What the figures are, said after the status: `funding = current estimate, pays 08:00 UTC`. */
78
84
  note?: string;
85
+ /** When the figures reach readers and when the next are due, said right after the as-of date. */
86
+ schedule?: string;
79
87
  /** Who published the figures, when the data names them. */
80
88
  publisher?: string;
81
89
  /** When the publisher released the data, an ISO instant in UTC or an ISO date, when the data states it. */
@@ -145,10 +153,12 @@ function readFeedSignals(record: Record<string, unknown>, signals: FeedSignals):
145
153
  function readSession(record: Record<string, unknown>, time: ReportTime | null, signals: FeedSignals): void {
146
154
  const exchange = record.sessionExchange;
147
155
  if (typeof exchange !== "string" || !exchange.trim()) return;
156
+ const calendar = readIndexSession(record.sessionCalendar);
148
157
  signals.sessions.push({
149
158
  exchange,
150
159
  ...(typeof record.marketState === "string" ? { marketState: record.marketState } : {}),
151
160
  time,
161
+ ...(calendar ? { calendar } : {}),
152
162
  });
153
163
  }
154
164
 
@@ -404,6 +414,7 @@ function resolveFreshness({ declared, signals, observations, now }: FreshnessInp
404
414
  }
405
415
  }
406
416
  const { market, closeDate, closeMarket } = reportMarketSession(signals.sessions, now);
417
+ const venueSessions = declared.datesEachVenue && !declaredAsOf ? reportVenueSessionDates(signals.sessions, now) : [];
407
418
  // The close wording dates the newest observation, so only when that is what `asOf` is.
408
419
  const asOfClose = closeDate && asOf && !declaredAsOf ? closeDate : null;
409
420
  const tradingDayMarket = asOfClose ? closeMarket
@@ -417,6 +428,7 @@ function resolveFreshness({ declared, signals, observations, now }: FreshnessInp
417
428
  const observation = lagDays != null ? declared.observation?.trim() : undefined;
418
429
 
419
430
  const note = declared.note?.trim();
431
+ const schedule = declared.schedule?.trim();
420
432
  const publisher = declared.publisher?.trim();
421
433
  const publishedAt = parseReportTime(declared.publishedAt);
422
434
  return {
@@ -434,11 +446,13 @@ function resolveFreshness({ declared, signals, observations, now }: FreshnessInp
434
446
  ...(asOfClose ? { asOfClose } : {}),
435
447
  ...(tradingDayMarket ? { tradingDayMarket } : {}),
436
448
  ...(market ? { market } : {}),
449
+ ...(venueSessions.length > 0 ? { venueSessions } : {}),
437
450
  ...(lagDays != null ? { lagDays } : {}),
438
451
  ...(observation ? { observation } : {}),
439
452
  ...(sources.length ? { sources: sources.map(({ source, time }) => ({ source, asOf: isoTime(time) })) } : {}),
440
453
  ...(oldestAgeMinutes != null ? { oldestAgeMinutes } : {}),
441
454
  ...(note ? { note } : {}),
455
+ ...(schedule ? { schedule } : {}),
442
456
  ...(publisher ? { publisher } : {}),
443
457
  ...(publishedAt ? { publishedAt: isoTime(publishedAt) } : {}),
444
458
  retrievedAt: new Date(now).toISOString(),
@@ -628,6 +642,26 @@ function onRetrievalDay(iso: string, retrievedAt: string): boolean {
628
642
  return iso.slice(0, 10) === retrievedAt.slice(0, 10);
629
643
  }
630
644
 
645
+ /** `US and XETRA`, `US, XETRA and EPA`. */
646
+ function listText(items: readonly string[]): string {
647
+ return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
648
+ }
649
+
650
+ /**
651
+ * Each market's session date, markets on the same day and state said once:
652
+ * `US, XETRA and EPA Tue 13 Oct 2026 close`, or `US Tue 13 Oct 2026 close,
653
+ * XETRA Wed 14 Oct 2026 session` while Xetra trades.
654
+ */
655
+ function venueSessionsText(sessions: readonly VenueSessionDate[]): string {
656
+ const groups: Array<{ markets: string[]; date: string; close: boolean }> = [];
657
+ for (const { market, date, close } of sessions) {
658
+ const group = groups.find((entry) => entry.date === date && entry.close === close);
659
+ if (group) group.markets.push(market);
660
+ else groups.push({ markets: [market], date, close });
661
+ }
662
+ return groups.map(({ markets, date, close }) => `${listText(markets)} ${dayText(date)} ${close ? "close" : "session"}`).join(", ");
663
+ }
664
+
631
665
  /**
632
666
  * The dated as-of: a session close, a UTC time, a date, or when the report was
633
667
  * retrieved; with the oldest observation unless `short`, or when the line says
@@ -636,6 +670,7 @@ function onRetrievalDay(iso: string, retrievedAt: string): boolean {
636
670
  */
637
671
  function asOfText(freshness: ReportFreshness, short: boolean, local: boolean, undated = false): string {
638
672
  if (!freshness.asOf) return `retrieved ${timeText(freshness.retrievedAt, local)}`;
673
+ if (freshness.venueSessions?.length) return venueSessionsText(freshness.venueSessions);
639
674
  const market = freshness.tradingDayMarket;
640
675
  const period = freshness.periodicity;
641
676
  const time = parseReportTime(freshness.asOf);
@@ -713,6 +748,8 @@ interface StatusLineDetail {
713
748
  date?: "none";
714
749
  /** Leave out what the figures are (`funding = current estimate`). */
715
750
  note?: "none";
751
+ /** Leave out when the figures are published and the next are due. */
752
+ schedule?: "none";
716
753
  }
717
754
 
718
755
  /**
@@ -728,6 +765,7 @@ function statusLineParts(freshness: ReportFreshness, detail: StatusLineDetail =
728
765
  return [
729
766
  ...(freshness.sources?.length ? sourcesAsOfParts(freshness, freshness.sources, undated)
730
767
  : [asOfText(freshness, detail.asOf === "short", local, undated)]),
768
+ ...(freshness.schedule && detail.schedule !== "none" ? [freshness.schedule] : []),
731
769
  ...(freshness.oldestAgeMinutes != null ? [`oldest ${ageText(freshness.oldestAgeMinutes)}`] : []),
732
770
  statusText(freshness, detail.status === "short"),
733
771
  ...(freshness.market && detail.market !== "none" ? [marketText(freshness.market, now, detail.market === "short", local)] : []),
@@ -812,13 +850,13 @@ export function paneStatusLine(freshness: ReportFreshness): string {
812
850
  /**
813
851
  * `paneStatusLine` from longest to shortest, for a footer to show the first
814
852
  * that fits beside its keys: the market part shortens, the stale total goes,
815
- * then what the figures are.
853
+ * then what the figures are, then when they are published.
816
854
  */
817
855
  export function paneStatusLineVariants(freshness: ReportFreshness): string[] {
818
856
  // A dataset's line has no market or local time to leave out: it reads as its report and capture do.
819
857
  if (datesObservation(freshness)) return statusLineVariants(freshness);
820
858
  const pane: StatusLineDetail = { local: "none", date: "none" };
821
- const details: StatusLineDetail[] = [pane, { ...pane, market: "short" }, { ...pane, market: "short", status: "short" }, { ...pane, market: "short", status: "short", note: "none" }];
859
+ const details: StatusLineDetail[] = [pane, { ...pane, market: "short" }, { ...pane, market: "short", status: "short" }, { ...pane, market: "short", status: "short", note: "none" }, { ...pane, market: "short", status: "short", note: "none", schedule: "none" }];
822
860
  return [...new Set(details.map((detail) => capitalize(statusLineParts(freshness, detail).join(" · "))))];
823
861
  }
824
862
 
@@ -855,9 +893,14 @@ export function statusLineVariants(freshness: ReportFreshness, { source = false
855
893
  // A line that dates each source keeps every source's time and their age to
856
894
  // the last: its day goes first, then what the figures are.
857
895
  const shortest = details.at(-1)!;
896
+ // What the figures are goes before when they are published.
897
+ const trailing: StatusLineDetail[] = [
898
+ ...(freshness.note ? [{ ...shortest, note: "none" as const }] : []),
899
+ ...(freshness.schedule ? [{ ...shortest, note: "none" as const, schedule: "none" as const }] : []),
900
+ ];
858
901
  const variants: StatusLineDetail[] = freshness.sources?.length
859
902
  ? [{}, ...details.map((detail) => ({ ...detail, date: "none" as const })), { ...shortest, date: "none", note: "none" }]
860
- : freshness.note ? [...details, { ...shortest, note: "none" }] : details;
903
+ : [...details, ...trailing];
861
904
  const lines = [...new Set(variants.map((detail) => capitalize(statusLineParts(freshness, detail).join(" · "))))];
862
905
  // A line that dates each source names them already.
863
906
  if (!source || freshness.sources?.length) return lines;
@@ -749,7 +749,7 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
749
749
  bloomberg: [],
750
750
  },
751
751
  FUT: {
752
- summary: "Front-month futures across equity index, rates, energy, metals, agriculture, livestock and FX with last price and session change, grouped by sector.",
752
+ summary: "Each root's most active futures contract across equity index, rates, energy, metals, agriculture, livestock and FX with last price, unit and session change, grouped by sector.",
753
753
  usage: ["FUT"],
754
754
  keys: [SEARCH, OPEN],
755
755
  data: same(DELAYED),
@@ -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
  }