gloomberb 0.18.2 → 0.18.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 (157) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +106 -5
  3. package/src/api-client/data.ts +15 -0
  4. package/src/api-client/index.ts +2 -0
  5. package/src/api-client/request.ts +5 -3
  6. package/src/api-client/telemetry.ts +17 -0
  7. package/src/app/global-shortcuts.ts +1 -16
  8. package/src/app/runtime/update.ts +28 -8
  9. package/src/architecture/pane-tabs-allowlist.json +65 -0
  10. package/src/cli/commands/market.ts +73 -33
  11. package/src/cli/commands/ticker.ts +82 -25
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +8 -0
  14. package/src/cli/issuer-membership.ts +103 -0
  15. package/src/cli/listing-arg.ts +15 -2
  16. package/src/cli/name-entries.ts +51 -12
  17. package/src/cli/pane-functions/capabilities.ts +2 -0
  18. package/src/cli/pane-functions/catalog.ts +16 -0
  19. package/src/cli/pane-functions/dom.ts +2 -1
  20. package/src/cli/pane-functions/headless.ts +25 -6
  21. package/src/cli/pane-functions/index.ts +64 -5
  22. package/src/cli/pane-functions/resolver.ts +19 -5
  23. package/src/cli/report-tables.ts +136 -18
  24. package/src/cli/result.ts +12 -4
  25. package/src/cli/symbol-list.ts +47 -11
  26. package/src/components/chart/composite/composite-chart.tsx +14 -5
  27. package/src/components/chart/composite/drawing-store.ts +26 -6
  28. package/src/components/chart/composite/interactions.ts +2 -1
  29. package/src/components/chart/composite/panel-surface.tsx +11 -0
  30. package/src/components/chart/composite/types.ts +6 -0
  31. package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
  32. package/src/components/command-bar/routes/root/command-items.ts +32 -6
  33. package/src/components/data-table/view.tsx +23 -3
  34. package/src/components/index.ts +1 -0
  35. package/src/components/layout/detached-pane-shell.tsx +1 -0
  36. package/src/components/layout/floating-pane.tsx +1 -0
  37. package/src/components/layout/header.tsx +228 -55
  38. package/src/components/layout/pane/header.tsx +10 -3
  39. package/src/components/layout/pane/index.tsx +1 -0
  40. package/src/components/layout/pane/pane-tabs.tsx +9 -1
  41. package/src/components/ui/stat-grid.tsx +28 -3
  42. package/src/components/ui/status.tsx +3 -1
  43. package/src/core/app-services.ts +4 -0
  44. package/src/i18n/es.ts +22 -0
  45. package/src/i18n/ja.ts +22 -0
  46. package/src/i18n/ko.ts +22 -0
  47. package/src/i18n/zh-cn.ts +22 -0
  48. package/src/i18n/zh-tw.ts +22 -0
  49. package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
  50. package/src/plugins/builtin/alerts/events.ts +1 -0
  51. package/src/plugins/builtin/alerts/research-rules.ts +1 -0
  52. package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
  53. package/src/plugins/builtin/attention/pane.tsx +16 -14
  54. package/src/plugins/builtin/awards/pane.tsx +6 -6
  55. package/src/plugins/builtin/backtest/export.ts +1 -1
  56. package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
  57. package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
  58. package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
  59. package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
  60. package/src/plugins/builtin/catalysts/client.ts +2 -2
  61. package/src/plugins/builtin/catalysts/pane.tsx +3 -3
  62. package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
  63. package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
  64. package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
  65. package/src/plugins/builtin/company-attention/pane.tsx +13 -11
  66. package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
  67. package/src/plugins/builtin/correlation/headless.ts +2 -1
  68. package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
  69. package/src/plugins/builtin/correlation/settings.ts +1 -0
  70. package/src/plugins/builtin/cot/pane.tsx +4 -3
  71. package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
  72. package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
  73. package/src/plugins/builtin/doe/pane.tsx +1 -1
  74. package/src/plugins/builtin/equity-screener/headless.ts +10 -0
  75. package/src/plugins/builtin/equity-screener/model.ts +28 -0
  76. package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
  77. package/src/plugins/builtin/exposure/pane.tsx +10 -9
  78. package/src/plugins/builtin/gpu/board.tsx +4 -1
  79. package/src/plugins/builtin/gpu/changes.tsx +10 -10
  80. package/src/plugins/builtin/gpu/equities.tsx +4 -1
  81. package/src/plugins/builtin/gpu/history.tsx +4 -2
  82. package/src/plugins/builtin/gpu/pane.tsx +5 -3
  83. package/src/plugins/builtin/holders/client.ts +2 -1
  84. package/src/plugins/builtin/holders/headless.ts +2 -1
  85. package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
  86. package/src/plugins/builtin/insider/base-values.ts +11 -7
  87. package/src/plugins/builtin/insider/client.ts +3 -0
  88. package/src/plugins/builtin/insider/headless.ts +19 -6
  89. package/src/plugins/builtin/insider/index.tsx +17 -8
  90. package/src/plugins/builtin/insider/issuer.ts +34 -4
  91. package/src/plugins/builtin/insider/signals-client.ts +2 -2
  92. package/src/plugins/builtin/insider/table-model.ts +35 -13
  93. package/src/plugins/builtin/insider/table.tsx +5 -2
  94. package/src/plugins/builtin/iv-history/events.ts +145 -0
  95. package/src/plugins/builtin/iv-history/format.ts +3 -0
  96. package/src/plugins/builtin/iv-history/headless.ts +91 -7
  97. package/src/plugins/builtin/jodi/pane.tsx +4 -4
  98. package/src/plugins/builtin/market-movers/client.ts +4 -3
  99. package/src/plugins/builtin/market-movers/screener.ts +4 -3
  100. package/src/plugins/builtin/notes/cli.ts +1 -1
  101. package/src/plugins/builtin/notes/export.ts +17 -6
  102. package/src/plugins/builtin/notes/files.ts +16 -5
  103. package/src/plugins/builtin/options/enrichment-model.ts +10 -9
  104. package/src/plugins/builtin/options/model-spot.ts +70 -4
  105. package/src/plugins/builtin/options/view.tsx +41 -13
  106. package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
  107. package/src/plugins/builtin/options-positioning/headless.ts +11 -3
  108. package/src/plugins/builtin/options-positioning/model.ts +24 -0
  109. package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
  110. package/src/plugins/builtin/options-scenario/client.ts +19 -1
  111. package/src/plugins/builtin/options-scenario/headless.ts +13 -6
  112. package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
  113. package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
  114. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  115. package/src/plugins/builtin/power/client.ts +2 -2
  116. package/src/plugins/builtin/power/pane.tsx +36 -31
  117. package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
  118. package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
  119. package/src/plugins/builtin/seasonality/client.ts +2 -1
  120. package/src/plugins/builtin/sectors/client.ts +4 -3
  121. package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
  122. package/src/plugins/builtin/shared/research-trading.tsx +2 -0
  123. package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
  124. package/src/plugins/builtin/shared/volatility/options.ts +2 -1
  125. package/src/plugins/builtin/social-mentions/client.ts +2 -1
  126. package/src/plugins/builtin/vol-surface/client.ts +23 -3
  127. package/src/plugins/builtin/vol-surface/headless.ts +33 -14
  128. package/src/plugins/builtin/vol-surface/model.ts +30 -10
  129. package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
  130. package/src/plugins/builtin/vol-surface/stored.ts +34 -3
  131. package/src/plugins/builtin/vol-surface/tables.ts +14 -0
  132. package/src/renderers/dom/desktop/controls.tsx +1 -1
  133. package/src/renderers/dom/desktop/query-bar.tsx +2 -0
  134. package/src/renderers/dom/host/tabs.tsx +16 -5
  135. package/src/renderers/dom/host/top-surface.ts +127 -0
  136. package/src/renderers/dom/styles.css +39 -0
  137. package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
  138. package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
  139. package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
  140. package/src/renderers/electrobun/view/news-source.ts +4 -2
  141. package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
  142. package/src/renderers/opentui/start.tsx +7 -0
  143. package/src/sources/gloomberb-cloud/index.ts +7 -0
  144. package/src/sources/provider-router/index.ts +16 -0
  145. package/src/telemetry/crash-reports.ts +4 -0
  146. package/src/telemetry/update-failures-node.ts +59 -0
  147. package/src/telemetry/update-failures.ts +307 -0
  148. package/src/tickers/company-name-listing.ts +9 -7
  149. package/src/tickers/company-name.ts +43 -0
  150. package/src/tickers/listing-fate.ts +30 -0
  151. package/src/types/data-provider.ts +6 -0
  152. package/src/types/headless.ts +7 -0
  153. package/src/types/issuer-listings.ts +28 -0
  154. package/src/updater/failure.ts +163 -0
  155. package/src/updater/index.ts +87 -23
  156. package/src/utils/currency-pair.ts +26 -0
  157. package/src/utils/sec.ts +68 -0
@@ -36,7 +36,8 @@ import {
36
36
  import type { FuturesContractTermsPayload } from "../../api-client/futures-curve";
37
37
  import { changeBasisJson, quoteChangeBasis, quotePreviousCloseDate } from "../../market-data/market/change-basis";
38
38
  import { futuresContractLines, futuresSettleText, isFuturesInstrument, loadFuturesContractTerms, minorUnitPricePer } from "../../market-data/futures-contract";
39
- import { currencyCodeLabel } from "../../utils/currency-units";
39
+ import { currencyCodeLabel, heldCurrencyUnit } from "../../utils/currency-units";
40
+ import { quotedFxPair } from "../../utils/currency-pair";
40
41
  import type { AppConfig } from "../../types/config";
41
42
  import type { DeclaredDividendAction, FinancialStatement, Quote, TickerFinancials } from "../../types/financials";
42
43
  import { atRegularSessionPrice, regularSessionValuationPrice } from "../../market-data/quotes/regular-session-valuation";
@@ -73,6 +74,7 @@ import {
73
74
  resolveCliListing, type CliListing, type ListingDataRequest,
74
75
  } from "../listing-arg";
75
76
  import { movedAwayChange } from "../../tickers/ticker-changes";
77
+ import { companyMemberships, loadCompanyMemberships, type CompanyMemberships, type ListMembership } from "../issuer-membership";
76
78
  import { isNotATickerMessage } from "../not-a-ticker";
77
79
  import { sharesOutstandingInReceipts } from "../../utils/depositary-receipt";
78
80
  import { cliFreshnessFooter } from "../result";
@@ -301,7 +303,7 @@ function shouldFetchSecFilings(
301
303
  }
302
304
 
303
305
  async function appendTickerPositions(lines: string[], tickerFile: TickerRecord | null, quote: TickerFinancials["quote"],
304
- config: AppConfig, toBase: ReportBaseConverter): Promise<void> {
306
+ config: AppConfig, toBase: ReportBaseConverter, rates: ReportRateNotes): Promise<void> {
305
307
  const quoteCurrency = quote?.currency?.trim() || tickerFile?.metadata.currency?.trim() || "";
306
308
  const codes = writesCurrencyCodes(config);
307
309
  const money = codes ? formatCurrencyCode : formatCurrency;
@@ -324,9 +326,10 @@ async function appendTickerPositions(lines: string[], tickerFile: TickerRecord |
324
326
  if (quoteCurrency && currentPrice != null) converted.add(quoteCurrency.toUpperCase());
325
327
  const baseMetrics = getPortfolioPositionMetrics({ ...tickerFile, metadata: { ...tickerFile.metadata, positions: [position] } }, undefined, quoteCurrency,
326
328
  { currency: config.baseCurrency, convert: value => value * positionRate }, quote);
327
- // A cost bought at an entered rate into the base currency converts at it, as its P&L does; any other at today's.
329
+ // A cost bought at an entered rate into the base currency converts at it, as its P&L does, and says so; any other at today's.
330
+ const boughtRate = positionCurrency ? lotCostFxRate(position, positionCurrency, config.baseCurrency) : null;
328
331
  const costBasisBase = !positionCurrency ? Number.NaN
329
- : lotCostFxRate(position, positionCurrency, config.baseCurrency) !== null ? baseMetrics.signedCost
332
+ : boughtRate !== null ? baseMetrics.signedCost
330
333
  : await toBase(metrics.signedCost, positionCurrency);
331
334
  const marketValueBase = resolvePortfolioMarketValue(baseMetrics, currentPriceBase)?.net ?? Number.NaN;
332
335
  const selectedPnl = resolvePortfolioPositionPnl(baseMetrics, currentPriceBase);
@@ -339,7 +342,8 @@ async function appendTickerPositions(lines: string[], tickerFile: TickerRecord |
339
342
  "Position",
340
343
  `${formatMarketQuantity(metrics.totalShares, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, quantityCurrency: positionCurrency })} ${metrics.priceBasis === "percent-of-par" ? "@" : `${tickerFile.metadata.assetCategory === "BOND" ? "units" : contracts ? "contracts" : "shares"} @`} ${positionCurrency ? formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, currencyCode: codes }) : "—"}`,
341
344
  ],
342
- ["Cost Basis", money(costBasisBase, config.baseCurrency)],
345
+ ["Cost Basis", `${money(costBasisBase, config.baseCurrency)}${boughtRate !== null
346
+ ? ` (${formatFxRate(boughtRate)} ${config.baseCurrency.trim().toUpperCase()} per ${positionCurrency} when bought)` : ""}`],
343
347
  ["Market Value", money(marketValueBase, config.baseCurrency)],
344
348
  [
345
349
  selectedPnl.basis === "broker-snapshot" ? "Broker P&L" : "P&L",
@@ -354,7 +358,7 @@ async function appendTickerPositions(lines: string[], tickerFile: TickerRecord |
354
358
  lines.push("");
355
359
  }
356
360
  }
357
- for (const note of conversionNotes(toBase, converted)) lines.push(cliStyles.muted(note));
361
+ for (const note of conversionNotes(toBase, converted, rates)) lines.push(cliStyles.muted(note));
358
362
  }
359
363
  }
360
364
 
@@ -372,14 +376,41 @@ export function writesCurrencyCodes(config: Pick<AppConfig, "baseCurrency"> | un
372
376
  }
373
377
 
374
378
  /**
375
- * "CAD at USD/CAD 1.40623, as of 2026-10-21 04:46 UTC.": the rate and its
376
- * date for each currency in `currencies` the report converted into the base
377
- * currency, so a converted figure is never without what it was converted at.
379
+ * What the report's rate notes have said and must say: each currency's rate
380
+ * is stated once, under the first section that converted it, and a lot whose
381
+ * cost converts at the rate entered when it was bought is left out of it.
378
382
  */
379
- function conversionNotes(toBase: ReportBaseConverter, currencies: ReadonlySet<string>): string[] {
383
+ interface ReportRateNotes {
384
+ /** Currencies whose rate a note already gave. */
385
+ noted: Set<string>;
386
+ /** Currencies of lots whose Cost Basis is at their own entered rate. */
387
+ boughtAt: ReadonlySet<string>;
388
+ }
389
+
390
+ /** The currencies of the shown lots whose cost converts into the base currency at the rate they were bought at. */
391
+ function boughtRateCurrencies(tickerFile: TickerRecord | null, quoteCurrency: string, baseCurrency: string): Set<string> {
392
+ return new Set((tickerFile?.metadata.positions ?? []).flatMap((position) => {
393
+ const currency = (position.currency || quoteCurrency).trim();
394
+ return position.shares !== 0 && currency && lotCostFxRate(position, currency, baseCurrency) !== null ? [currency.toUpperCase()] : [];
395
+ }));
396
+ }
397
+
398
+ /**
399
+ * "CAD figures at USD/CAD 1.40623, as of 2026-10-21 04:46 UTC.": the rate
400
+ * and its date for each currency in `currencies` the report converted into
401
+ * the base currency, as markets quote the pair (USD/JPY, not JPY/USD), so a
402
+ * converted figure is never without what it was converted at. A rate already
403
+ * stated is not repeated, and a cost basis at its own rate is excepted.
404
+ */
405
+ function conversionNotes(toBase: ReportBaseConverter, currencies: ReadonlySet<string>, rates: ReportRateNotes): string[] {
380
406
  return (toBase.conversions?.() ?? [])
381
- .filter((conversion) => currencies.has(conversion.currency) && conversion.currency !== conversion.baseCurrency)
382
- .map(({ currency, baseCurrency, rate, asOf }) => `${baseCurrency} figures at ${currency}/${baseCurrency} ${formatFxRate(rate)}${asOf != null ? `, as of ${formatTimestamp(asOf)}` : ""}.`);
407
+ .filter((conversion) => currencies.has(conversion.currency) && conversion.currency !== conversion.baseCurrency && !rates.noted.has(conversion.currency))
408
+ .map(({ currency, baseCurrency, rate, asOf }) => {
409
+ rates.noted.add(currency);
410
+ const quoted = quotedFxPair(currency, baseCurrency, rate);
411
+ const except = rates.boughtAt.has(currency) ? ", except a cost basis at the rate it was bought at" : "";
412
+ return `${baseCurrency} figures at ${quoted.pair} ${formatFxRate(quoted.rate)}${asOf != null ? `, as of ${formatTimestamp(asOf)}` : ""}${except}.`;
413
+ });
383
414
  }
384
415
 
385
416
  /** A source's enterprise value is in the units of the capitalization it reports beside it,
@@ -947,8 +978,11 @@ export async function buildTickerReport({
947
978
  distributions = null,
948
979
  statementFilings = null,
949
980
  bondFund = null,
981
+ memberships,
950
982
  }: {
951
983
  symbol: string;
984
+ /** The lists holding the company, by any of its listings (loadCompanyMemberships); the saved listing's own lists when absent. */
985
+ memberships?: CompanyMemberships;
952
986
  /** The filings behind the latest statements, and a newer quarter's results release. */
953
987
  statementFilings?: StatementFilingsReport | null;
954
988
  /** A bond fund's SEC yield, yield to worst and duration (loadReportBondFacts). */
@@ -981,6 +1015,10 @@ export async function buildTickerReport({
981
1015
  const baseQuoteOptions = quoteFormatOptions(quote, tickerFile?.metadata.assetCategory, financials.quoteMetadata?.instrumentType);
982
1016
  const baseCurrency = config.baseCurrency.trim().toUpperCase();
983
1017
  const codes = writesCurrencyCodes(config);
1018
+ const rates: ReportRateNotes = {
1019
+ noted: new Set(),
1020
+ boughtAt: boughtRateCurrencies(tickerFile, quote?.currency?.trim() || tickerFile?.metadata.currency?.trim() || "", config.baseCurrency),
1021
+ };
984
1022
  // Pad money prices to the currency's minor unit so a range reads £35.10 - £35.485, never past it (JPY has none).
985
1023
  const quoteOptions = withCurrencyMinorDigits(codes ? { ...baseQuoteOptions, currencyCode: true } : baseQuoteOptions, quote?.currency);
986
1024
  const lines: string[] = [];
@@ -1015,14 +1053,15 @@ export async function buildTickerReport({
1015
1053
  lines.push(cliStyles.muted(metadataParts.join(METADATA_SEPARATOR)));
1016
1054
  }
1017
1055
 
1018
- const portfolioNames = tickerFile ? formatPortfolioNames(config, tickerFile.metadata.portfolios) : [];
1019
- const watchlistNames = tickerFile ? formatWatchlistNames(config, tickerFile.metadata.watchlists) : [];
1056
+ // A list that holds the company as another of its listings says which: "Watchlists R (as RCI.B:TSX)".
1057
+ const lists = memberships ?? companyMemberships(config, tickerFile, [], null);
1058
+ const listNames = (held: readonly ListMembership[]) => held.map((list) => list.via ? `${list.name} (as ${list.via})` : list.name).join(", ");
1020
1059
  const membershipParts = [
1021
- portfolioNames.length > 0
1022
- ? `Portfolios ${portfolioNames.join(", ")}`
1060
+ lists.portfolios.length > 0
1061
+ ? `Portfolios ${listNames(lists.portfolios)}`
1023
1062
  : undefined,
1024
- watchlistNames.length > 0
1025
- ? `Watchlists ${watchlistNames.join(", ")}`
1063
+ lists.watchlists.length > 0
1064
+ ? `Watchlists ${listNames(lists.watchlists)}`
1026
1065
  : undefined,
1027
1066
  ].filter((part): part is string => !!part);
1028
1067
  if (membershipParts.length > 0) {
@@ -1036,10 +1075,13 @@ export async function buildTickerReport({
1036
1075
  // with the base currency's figure after them when it differs: 627.2B USD (878.15B CAD).
1037
1076
  const convertedCurrencies = new Set<string>();
1038
1077
  const inBase = async (value: number, currency: string): Promise<string> => {
1039
- if (currency.trim().toUpperCase() === baseCurrency) return "";
1040
- const converted = await toBase(value, currency);
1078
+ // A minor unit converts as the hundredth of its currency it is: 2.75T GBp is 27.5B GBP, never 2.75T GBP.
1079
+ const unit = heldCurrencyUnit(currency);
1080
+ if (!unit.currency || (unit.currency === baseCurrency && unit.divisor === 1)) return "";
1081
+ const major = value / unit.divisor;
1082
+ const converted = unit.currency === baseCurrency ? major : await toBase(major, unit.currency);
1041
1083
  if (!Number.isFinite(converted)) return "";
1042
- convertedCurrencies.add(currency.trim().toUpperCase());
1084
+ if (unit.currency !== baseCurrency) convertedCurrencies.add(unit.currency);
1043
1085
  return ` (${formatCompact(converted)} ${baseCurrency})`;
1044
1086
  };
1045
1087
  const marketCapText = capitalization
@@ -1048,8 +1090,7 @@ export async function buildTickerReport({
1048
1090
  // Shown in the same currency as the market cap, so the two can be compared.
1049
1091
  const enterpriseValue = reportedEnterpriseValue(priced.fundamentals);
1050
1092
  const evCurrency = enterpriseValueCurrency(quote, fundamentals);
1051
- // A minor unit such as GBp stays as reported: the converter would read it as the major currency.
1052
- const enterpriseValueText = `${formatReportedMoney(enterpriseValue, evCurrency)}${enterpriseValue != null && evCurrency && /^[A-Z]{3}$/.test(evCurrency)
1093
+ const enterpriseValueText = `${formatReportedMoney(enterpriseValue, evCurrency)}${enterpriseValue != null && evCurrency
1053
1094
  ? await inBase(enterpriseValue, evCurrency) : ""}`;
1054
1095
 
1055
1096
  if (quote) {
@@ -1099,7 +1140,7 @@ export async function buildTickerReport({
1099
1140
  const beforeFundamentals = lines.length;
1100
1141
  appendMetricSection(lines, "Fundamentals", metricLines(metrics));
1101
1142
  for (const note of fundamentalsNotes(priced, metrics, quote?.currency)) lines.push(cliStyles.muted(note));
1102
- if (lines.length > beforeFundamentals) for (const note of conversionNotes(toBase, convertedCurrencies)) lines.push(cliStyles.muted(note));
1143
+ if (lines.length > beforeFundamentals) for (const note of conversionNotes(toBase, convertedCurrencies, rates)) lines.push(cliStyles.muted(note));
1103
1144
  // Where a fund's facts would go: under its fundamentals, or in their place.
1104
1145
  const fund = fundFactsNote(quote?.symbol ?? symbol,
1105
1146
  quote?.tradingExchangeName || listingExchange || quote?.listingExchangeName || quote?.exchangeName || tickerFile?.metadata.exchange, financials, tickerFile);
@@ -1159,7 +1200,7 @@ export async function buildTickerReport({
1159
1200
  link: filing.filingUrl,
1160
1201
  })), hiddenFilingsNote(hiddenSecFilings));
1161
1202
 
1162
- await appendTickerPositions(lines, tickerFile, quote, config, toBase);
1203
+ await appendTickerPositions(lines, tickerFile, quote, config, toBase, rates);
1163
1204
 
1164
1205
  return lines.join("\n");
1165
1206
  }
@@ -1178,9 +1219,11 @@ function buildTickerStructuredData({
1178
1219
  distributions,
1179
1220
  statementFilings,
1180
1221
  bondFund = null,
1222
+ memberships,
1181
1223
  }: {
1182
1224
  symbol: string;
1183
1225
  listing: CliListing;
1226
+ memberships: CompanyMemberships;
1184
1227
  statementFilings: StatementFilingsReport | null;
1185
1228
  bondFund?: FundBondFacts | null;
1186
1229
  futuresContract: FuturesContractTermsPayload | null;
@@ -1236,6 +1279,8 @@ function buildTickerStructuredData({
1236
1279
  watchlists: formatWatchlistNames(config, tickerFile.metadata.watchlists),
1237
1280
  positions: tickerFile.metadata.positions,
1238
1281
  } : null,
1282
+ // The lists holding the company by any of its listings; `via` names the other listing a list holds it as.
1283
+ memberships,
1239
1284
  fundamentals: financials.fundamentals || priceReturns.return1Y != null || priceReturns.return3Y != null ? exportedDividendFundamentals(exportedFundamentals({
1240
1285
  ...priced.fundamentals,
1241
1286
  ...priceReturns,
@@ -1368,6 +1413,15 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1368
1413
  quarter: latestFinancialPeriod(resolvedFinancials.quarterlyStatements, (row) => row.date),
1369
1414
  annual: latestFinancialPeriod(resolvedFinancials.annualStatements, (row) => row.date),
1370
1415
  });
1416
+ // A list holding the company as another of its listings counts (a list of RCI.B:TSX holds RCI); read beside the rest. It never fails.
1417
+ const membershipsLoad = loadCompanyMemberships({
1418
+ config,
1419
+ own: tickerFile,
1420
+ symbol: listing.symbol,
1421
+ exchange: canonicalExchange(listing.exchange || quote?.listingExchangeName || quote?.exchangeName || tickerFile?.metadata.exchange),
1422
+ loadTickers: () => store.loadAllTickers(),
1423
+ dataProvider,
1424
+ });
1371
1425
  const [notesResult, newsResult, secFilingsResult] = await Promise.allSettled([
1372
1426
  notesFiles.load(tickerFile?.metadata.ticker ?? normalized),
1373
1427
  dataProvider.getNews({
@@ -1401,6 +1455,7 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1401
1455
  const coinNote = listing.exchange ? null
1402
1456
  : notTheCoinNote(listing.symbol, quote ?? (tickerFile ? { name: tickerFile.metadata.name, instrumentType: tickerFile.metadata.assetCategory } : null));
1403
1457
  const listingNote = [choiceNote, coinNote].filter((note): note is string => !!note).join("\n") || undefined;
1458
+ const memberships = await membershipsLoad;
1404
1459
  if (dependencies.printResult) {
1405
1460
  const warnings = [choiceNote, coinNote, quoteNote].filter((note): note is string => !!note);
1406
1461
  dependencies.printResult({
@@ -1420,6 +1475,7 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1420
1475
  distributions: await distributions,
1421
1476
  statementFilings: await statementFilings,
1422
1477
  bondFund: await bondFund,
1478
+ memberships,
1423
1479
  }),
1424
1480
  });
1425
1481
  return;
@@ -1442,6 +1498,7 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1442
1498
  distributions: await distributions,
1443
1499
  statementFilings: await statementFilings,
1444
1500
  bondFund: await bondFund,
1501
+ memberships,
1445
1502
  }));
1446
1503
  if (freshness) console.log(`\n${cliFreshnessFooter(freshness)}`);
1447
1504
  });
package/src/cli/help.ts CHANGED
@@ -58,7 +58,7 @@ const GLOBAL_OPTIONS: Array<[string, string]> = [
58
58
 
59
59
  const EXIT_CODES: Array<[string, string]> = [
60
60
  [String(CLI_EXIT_CODES.ok), "Success"],
61
- [String(CLI_EXIT_CODES.error), "Failed: bad input, no data found or a failed check"],
61
+ [String(CLI_EXIT_CODES.error), "Failed: bad input, no data found or a failed check. fn exits 0 on partial data with warnings; --json ok is false exactly when this is 1"],
62
62
  [String(CLI_EXIT_CODES.unusableShot), "shot --strict saved an image of a pane with no usable data"],
63
63
  ];
64
64
 
package/src/cli/index.ts CHANGED
@@ -190,8 +190,16 @@ export function createCoreCliCommands(
190
190
  "fn WEI --csv --section europe > europe.csv",
191
191
  "fn 13F AAPL --view=ticker-holdings",
192
192
  "fn OVME --spot 100 --strike 100 --days 30 --volatility 25",
193
+ "fn HIVG SPY --event fomc --offset -6 --lookback ALL --csv",
193
194
  "fn AUCT --explain",
194
195
  ],
196
+ sections: [{
197
+ title: "Exit status",
198
+ lines: [
199
+ "0 when the report has data for what was asked, partial data with warnings about the rest included; 1 when it has none (an empty report, or rows with no value for a listing that has nothing) or the input is bad. --json ok is false exactly when the exit status is 1.",
200
+ "CSV headers are the same for every symbol: a reason a figure is missing goes to the status column, never into a number cell.",
201
+ ],
202
+ }],
195
203
  },
196
204
  execute: async (args, ctx) => {
197
205
  requirePaneFunctionTarget(args, "fn", ctx);
@@ -0,0 +1,103 @@
1
+ import type { AppConfig } from "../types/config";
2
+ import type { DataProvider } from "../types/data-provider";
3
+ import type { TickerRecord } from "../types/ticker";
4
+ import { canonicalExchange, isUsListingExchange, parsePublicTickerKey } from "../utils/exchanges";
5
+ import { logFailure } from "../utils/log-failure";
6
+ import { savedListingName } from "./listing-arg";
7
+
8
+ /** A list holding the company: its name, and the other saved listing it holds the company as (null for the listing asked about). */
9
+ export interface ListMembership {
10
+ name: string;
11
+ /** RCI.B:TSX when the list holds Rogers as its Toronto line and the report is of RCI. */
12
+ via: string | null;
13
+ }
14
+
15
+ export interface CompanyMemberships {
16
+ portfolios: ListMembership[];
17
+ watchlists: ListMembership[];
18
+ }
19
+
20
+ /**
21
+ * A listing as Gloom Cloud writes an issuer's listings: the bare ticker on a
22
+ * US venue or none (RCI), SYMBOL:EXCHANGE elsewhere (RCI.B:TSX), with a
23
+ * share class's dash or slash as a dot (BRK-B is BRK.B).
24
+ */
25
+ export function issuerListingKey(symbol: string, exchange?: string): string {
26
+ const parsed = parsePublicTickerKey(symbol);
27
+ const venue = parsed.exchange ?? canonicalExchange(exchange);
28
+ const code = parsed.symbol.toUpperCase().replace(/[-/]/g, ".");
29
+ return venue && !isUsListingExchange(venue) ? `${code}:${venue}` : code;
30
+ }
31
+
32
+ function names(config: AppConfig, ids: readonly string[], kind: "portfolios" | "watchlists"): Map<string, string> {
33
+ const collections: ReadonlyArray<{ id: string; name: string }> = config[kind];
34
+ return new Map(ids.flatMap((id) => {
35
+ const name = collections.find((collection) => collection.id === id)?.name;
36
+ return name ? [[id, name] as const] : [];
37
+ }));
38
+ }
39
+
40
+ /**
41
+ * The portfolios and watchlists that hold a company, whichever of its
42
+ * listings they saved: the listing's own lists, then those holding another
43
+ * of `issuerListings` (its keys as `issuerListingKey` writes them), each
44
+ * once and saying which listing it holds. A restricted list holding RCI.B:TSX
45
+ * holds Rogers, which also trades as RCI in New York. Without the company's
46
+ * listings, only the listing's own lists.
47
+ */
48
+ export function companyMemberships(
49
+ config: AppConfig,
50
+ own: TickerRecord | null,
51
+ saved: readonly TickerRecord[],
52
+ issuerListings: readonly string[] | null,
53
+ ): CompanyMemberships {
54
+ const listings = new Set(issuerListings ?? []);
55
+ const ownKey = own ? savedListingName(own).key : null;
56
+ const others = listings.size === 0 ? [] : saved.filter((ticker) =>
57
+ savedListingName(ticker).key !== ownKey
58
+ && listings.has(issuerListingKey(ticker.metadata.ticker, ticker.metadata.exchange)));
59
+ const collect = (kind: "portfolios" | "watchlists"): ListMembership[] => {
60
+ const held = new Map<string, ListMembership>();
61
+ for (const [id, name] of names(config, own?.metadata[kind] ?? [], kind)) held.set(id, { name, via: null });
62
+ for (const ticker of others) {
63
+ for (const [id, name] of names(config, ticker.metadata[kind], kind)) {
64
+ if (!held.has(id)) held.set(id, { name, via: savedListingName(ticker).key });
65
+ }
66
+ }
67
+ return [...held.values()];
68
+ };
69
+ return { portfolios: collect("portfolios"), watchlists: collect("watchlists") };
70
+ }
71
+
72
+ /**
73
+ * `companyMemberships` for a listing, asking Gloom Cloud for the company's
74
+ * other listings only when another saved listing is in a list at all. A
75
+ * failed lookup leaves the listing's own lists.
76
+ */
77
+ export async function loadCompanyMemberships({
78
+ config,
79
+ own,
80
+ symbol,
81
+ exchange,
82
+ loadTickers,
83
+ dataProvider,
84
+ }: {
85
+ config: AppConfig;
86
+ own: TickerRecord | null;
87
+ /** The listing the report is of, and its venue (RCI, NYSE). */
88
+ symbol: string;
89
+ exchange: string;
90
+ loadTickers: () => Promise<TickerRecord[]>;
91
+ dataProvider: Pick<DataProvider, "getIssuerListings">;
92
+ }): Promise<CompanyMemberships> {
93
+ const ownKey = own ? savedListingName(own).key : null;
94
+ const saved = await Promise.resolve().then(loadTickers)
95
+ .catch(logFailure("cli.issuer-membership", "Reading saved listings failed", [] as TickerRecord[]));
96
+ const listed = saved.filter((ticker) => savedListingName(ticker).key !== ownKey
97
+ && (ticker.metadata.portfolios.length > 0 || ticker.metadata.watchlists.length > 0));
98
+ const { getIssuerListings } = dataProvider;
99
+ if (listed.length === 0 || !getIssuerListings) return companyMemberships(config, own, [], null);
100
+ const issuer = await Promise.resolve().then(() => getIssuerListings.call(dataProvider, symbol, exchange))
101
+ .catch(logFailure("cli.issuer-membership", "Reading the company's listings failed", null));
102
+ return companyMemberships(config, own, listed, issuer?.listings.map((listing) => issuerListingKey(listing.symbol, listing.exchange)) ?? null);
103
+ }
@@ -836,6 +836,13 @@ export async function loadSavedListing(store: ListingStore, listing: ListingArg)
836
836
  return null;
837
837
  }
838
838
 
839
+ /** A row saved under a vendor's suffixed key (RY.TO) for the listing it was read as: on that venue, or on none it names. */
840
+ async function loadSuffixedRow(store: ListingStore, key: string, exchange: string): Promise<TickerRecord | null> {
841
+ const ticker = await store.loadTicker(key);
842
+ const savedExchange = canonicalExchange(ticker?.metadata.exchange);
843
+ return ticker && (!savedExchange || savedExchange === exchange) ? ticker : null;
844
+ }
845
+
839
846
  export interface CliListing extends ListingArg {
840
847
  saved: TickerRecord | null;
841
848
  /**
@@ -895,7 +902,11 @@ export async function resolveCliListing(
895
902
  deps: ListingDeps,
896
903
  options: {
897
904
  ownExchangeWins?: boolean;
898
- /** Read a venue suffix (RY.TO) as the listing its quote names (RY:TSX): `fn` and `shot`, whose functions read data by venue. */
905
+ /**
906
+ * Read a venue suffix (RY.TO) as the listing its quote names (RY:TSX).
907
+ * Only pane functions ask for it (`applyListingArgument`, which `fn` and
908
+ * `shot` run through), as their data is read by venue.
909
+ */
899
910
  venueSuffix?: boolean;
900
911
  } = {},
901
912
  ): Promise<CliListing> {
@@ -913,6 +924,8 @@ export async function resolveCliListing(
913
924
  const pair = parsed.exchange ? null : await readAmbiguousCurrencyPair(parsed.symbol, deps);
914
925
  if (pair) parsed = { symbol: pair, exchange: "", key: pair };
915
926
  const suffixed = pair || named || !options.venueSuffix ? null : await readVenueSuffix(parsed, deps);
927
+ // The row may be saved under the suffixed key itself: a link or a layout's key is kept as it came.
928
+ const typedKey = suffixed ? parsed.symbol : null;
916
929
  if (suffixed) parsed = parsed.readAs ? { ...suffixed, readAs: `${parsed.readAs} ${suffixed.readAs}` } : suffixed;
917
930
  try {
918
931
  await checkListingExchange(parsed, deps);
@@ -922,7 +935,7 @@ export async function resolveCliListing(
922
935
  parsed = market;
923
936
  }
924
937
  let listing = await settleSharedCode(parsed, deps);
925
- const saved = await loadSavedListing(deps.store, listing);
938
+ const saved = await loadSavedListing(deps.store, listing) ?? (typedKey ? await loadSuffixedRow(deps.store, typedKey, listing.exchange) : null);
926
939
  // A code nothing could read stays the venue it is saved on.
927
940
  const savedExchange = canonicalExchange(saved?.metadata.exchange);
928
941
  if (listing.sharedCode && savedExchange) listing = { symbol: listing.symbol, exchange: savedExchange, key: `${listing.symbol}:${savedExchange}` };
@@ -6,26 +6,65 @@ import { splitSymbolWords } from "./symbol-args";
6
6
  import { isProviderBusy } from "../sources/provider-errors";
7
7
 
8
8
  /**
9
- * The entries of a list (`splitSymbolArgs`) as the symbols a command
10
- * resolves. An entry with a space in it is a company's name when search finds
11
- * the company (`Whirlpool Corporation`), and stays whole for its listing to
12
- * say what it was read as; otherwise it is its words, each a symbol, as a
13
- * pasted line always was (`brk.b bf-b`). Only those entries cost a search.
14
- * One search was too busy to look up also stays whole, so its lookup says so
15
- * instead of resolving its words as symbols of other companies.
9
+ * The entries of a list (`splitSymbolArgs`, or a file's lines) as the symbols
10
+ * a command resolves. An entry with a space in it is a company's name when
11
+ * search finds the company (`Whirlpool Corporation`, `philip morris intl`),
12
+ * and stays whole for its listing to say what it was read as. One search
13
+ * finds no company for is its words, each a symbol, when each is written as
14
+ * a symbol or has a quote (`AAPL MSFT`, `brk.b bf-b`, `aapl msft` on a
15
+ * pasted line), and otherwise stays whole, a name nothing lists: its words
16
+ * are not taken for other companies' symbols (INTL, a fund, from a misspelt
17
+ * `phillip morris intl`). Only those entries cost a search. One search was
18
+ * too busy to look up also stays whole, so its lookup says so instead of
19
+ * resolving its words as symbols of other companies.
16
20
  */
17
21
  export async function readNameEntries(
18
22
  entries: readonly string[],
19
- dataProvider: Pick<DataProvider, "search">,
23
+ dataProvider: Pick<DataProvider, "search"> & Partial<Pick<DataProvider, "getQuotesBatch" | "getQuote">>,
20
24
  ): Promise<string[]> {
21
- const read = await Promise.all(entries.map(async (entry) => (
25
+ const read = await Promise.all(entries.map(async (entry) => {
22
26
  // A terminal's code for a listing ("JPM US Equity") is one entry, like a company's name.
23
- await readTerminalCodeArg(dataProvider, entry) || (isCompanyNameInput(entry) && await findListingOfCompanyName(dataProvider, entry).catch(busyAsFound))
24
- ? [entry] : splitSymbolWords([entry])
25
- )));
27
+ if (await readTerminalCodeArg(dataProvider, entry)) return [entry];
28
+ if (!isCompanyNameInput(entry)) return splitSymbolWords([entry]);
29
+ if (await findListingOfCompanyName(dataProvider, entry).catch(busyAsFound)) return [entry];
30
+ const words = splitSymbolWords([entry]);
31
+ return await wordsAreSymbols(words, dataProvider) ? words : [entry];
32
+ }));
26
33
  return read.flat();
27
34
  }
28
35
 
36
+ /** A word typed as a ticker is: in capitals (AAPL, 7203.T), with a venue or class mark (brk.b, bf-b, SAN:EPA), or after a $. */
37
+ function writtenAsSymbol(word: string): boolean {
38
+ return /^\$|[:.\-/=^]/.test(word) || /\d/.test(word) || (/\p{Lu}/u.test(word) && !/\p{Ll}/u.test(word));
39
+ }
40
+
41
+ /**
42
+ * Whether the words of an entry search found no company for are symbols:
43
+ * each written as one (2222, BRK.B) or else with a quote, the others asked in
44
+ * one batch (`2222 amd pltr`). A batch that fails says nothing, and the
45
+ * entry stays a name.
46
+ */
47
+ async function wordsAreSymbols(
48
+ words: readonly string[],
49
+ dataProvider: Partial<Pick<DataProvider, "getQuotesBatch" | "getQuote">>,
50
+ ): Promise<boolean> {
51
+ const symbols = words.filter((word) => !writtenAsSymbol(word)).map((word) => word.replace(/^\$/, "").toUpperCase());
52
+ if (symbols.length === 0) return true;
53
+ if (dataProvider.getQuotesBatch) {
54
+ const results = await dataProvider.getQuotesBatch(symbols.map((symbol) => ({ symbol, exchange: "" })))
55
+ .catch(logFailure("cli.name-entries", "Read quotes for a pasted line failed", []));
56
+ return results.length === symbols.length && results.every(({ quote }) => !!quote);
57
+ }
58
+ // A source without batches is asked a quote at a time: the few words not written as symbols.
59
+ const { getQuote } = dataProvider;
60
+ if (!getQuote) return false;
61
+ const quoted = await Promise.all(symbols.map((symbol) => getQuote(symbol, "").then(
62
+ (quote) => !!quote,
63
+ logFailure("cli.name-entries", "Read a quote for a pasted line failed", false),
64
+ )));
65
+ return quoted.every(Boolean);
66
+ }
67
+
29
68
  /** `findListingOfCompanyName`'s busy answer as a name to keep whole, any other failure as it is. */
30
69
  function busyAsFound(error: unknown): true {
31
70
  if (isProviderBusy(error)) return true;
@@ -18,6 +18,8 @@ export interface PaneFunctionCapability {
18
18
  botSafe: boolean;
19
19
  tickerCardinality: PaneFunctionTickerCardinality;
20
20
  aliases: string[];
21
+ /** Words that are no code but ask for this function's data: `fn` and `catalog` answer them with it. */
22
+ suggestFor?: string[];
21
23
  intents: string[];
22
24
  outputKind: string;
23
25
  reportReadiness: PaneFunctionReadiness;
@@ -210,6 +210,21 @@ export function nearFunctionCodes(
210
210
  .sort((left, right) => left.code.localeCompare(right.code));
211
211
  }
212
212
 
213
+ /**
214
+ * The functions a word that is no code asks for (`IVOL`, `IV`: HIVG and
215
+ * OVDV), as their `discovery.suggestFor` lists it, each by its code.
216
+ */
217
+ export function suggestedFunctionCodes(registry: PaneFunctionCatalog, name: string): NearFunctionCode[] {
218
+ const wanted = normalizeLookupToken(name);
219
+ if (!wanted) return [];
220
+ return [...registry.paneTemplates.values()].flatMap((template) => {
221
+ const pane = registry.panes.get(template.paneId);
222
+ const words = pane ? getHeadlessPaneDefinition(template, pane)?.discovery?.suggestFor ?? [] : [];
223
+ const code = template.shortcut?.prefix;
224
+ return code && words.some((word) => normalizeLookupToken(word) === wanted) ? [{ code: code.toUpperCase(), label: template.label }] : [];
225
+ }).sort((left, right) => left.code.localeCompare(right.code));
226
+ }
227
+
213
228
  function sampleArgForTemplate(template: PaneTemplateDef): string {
214
229
  switch (template.shortcut?.argKind) {
215
230
  case "ticker":
@@ -391,6 +406,7 @@ function scorePaneCatalogEntry(entry: PaneCatalogEntry, query: string): CatalogM
391
406
  entry.token,
392
407
  entry.shortcut,
393
408
  ...entry.aliases,
409
+ ...entry.capability.suggestFor ?? [],
394
410
  entry.templateId,
395
411
  entry.paneId,
396
412
  ].filter((value): value is string => !!value).map((value) => normalizeLookupToken(value));
@@ -222,7 +222,8 @@ export async function buildDomPaneReportFromRender(
222
222
  const truncated = truncationReasons.length > 0
223
223
  || (result.render.reportTruncationReasons ? false : result.render.truncated);
224
224
  const failureReason = renderedFailureReason(result, rows);
225
- const unavailableSymbols = failureReason && result.symbols.length > 0 ? result.symbols : [];
225
+ // A pane that drew rows beside an error or empty marker ("Quote unavailable.") has data: only one with none has no data for its symbols.
226
+ const unavailableSymbols = failureReason && rows.length === 0 && result.symbols.length > 0 ? result.symbols : [];
226
227
  // What the view leaves out and how to see more, such as OMON's strikes outside its window.
227
228
  const rendered = renderedReportNotices(result.render.semanticUi ?? []);
228
229
  const notices = [...(resolved.pane.reportNotices?.(resolved.instance.settings ?? {}) ?? []), ...rendered.notices];
@@ -89,17 +89,36 @@ function normalizeSymbolList(value: string): string[] {
89
89
  return symbols;
90
90
  }
91
91
 
92
- function validateSymbolCount(
92
+ const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five"];
93
+ const EXAMPLE_TICKERS = ["SPY", "QQQ", "IWM", "DIA", "TLT"];
94
+
95
+ /**
96
+ * What a function that compares several tickers answers fewer: how many it
97
+ * needs and a command that runs, the reader's own tickers first (`CMP needs
98
+ * two or more tickers: fn CMP SPY,QQQ`).
99
+ */
100
+ function tooFewTickersMessage(token: string, minimum: number, given: readonly string[], command: "fn" | "shot" = "fn"): string {
101
+ const example = [...given, ...EXAMPLE_TICKERS.filter((ticker) => !given.includes(ticker))].slice(0, Math.max(minimum, given.length + 1));
102
+ return `${token} needs ${COUNT_WORDS[minimum] ?? minimum} or more tickers: ${command} ${token} ${example.join(",")}`;
103
+ }
104
+
105
+ /** The tickers a function's argument lists, as its report reads them. */
106
+ export function argumentSymbols(rawArgument: string): string[] {
107
+ return normalizeSymbolList(rawArgument);
108
+ }
109
+
110
+ export function validateSymbolCount(
93
111
  definition: HeadlessPaneDefinition,
94
112
  token: string,
95
113
  symbols: string[],
114
+ command: "fn" | "shot" = "fn",
96
115
  ): void {
97
116
  const minimum = definition.argument.minimum
98
117
  ?? (definition.argument.optional ? 0 : 1);
99
118
  const maximum = definition.argument.maximum;
100
119
  if (symbols.length < minimum) {
101
120
  if (minimum <= 1) throw requiredArgumentError(definition, token);
102
- throw new Error(`${token} requires at least ${minimum} symbols.`);
121
+ throw new Error(tooFewTickersMessage(token, minimum, symbols, command));
103
122
  }
104
123
  if (maximum != null && symbols.length > maximum) {
105
124
  throw new Error(`${token} accepts at most ${maximum} symbols.`);
@@ -735,14 +754,14 @@ export function headlessReportTables(
735
754
  const rowsResult = result as HeadlessRowsResult;
736
755
  const columns = displayedColumns(columnsFor(rowsResult.rows, rowsResult.columns, definition.columns));
737
756
  const rowIdentities = rowIdentitiesOf(rowsResult.rows, rowsResult);
738
- const own = exportRowsTable(title, columns, rowsResult.rows);
757
+ const own = exportRowsTable(title, columns, rowsResult.rows, { status: true });
739
758
  const interval = rowsResult.interval?.trim();
740
759
  tables = [{ ...own, ...(rowIdentities ? { rowIdentities } : {}), ...(interval ? { identity: { interval } } : {}) }, ...(rowsResult.exportTables ?? []).map((extra) => {
741
760
  const identity = {
742
761
  ...(extra.unit?.trim() ? { currencyOrUnit: extra.unit.trim() } : {}),
743
762
  ...(extra.interval?.trim() ? { interval: extra.interval.trim() } : {}),
744
763
  };
745
- const table = exportRowsTable(extra.title, displayedColumns(extra.columns), extra.rows);
764
+ const table = exportRowsTable(extra.title, displayedColumns(extra.columns), extra.rows, { status: true });
746
765
  return Object.keys(identity).length > 0 ? { ...table, identity } : table;
747
766
  })];
748
767
  break;
@@ -758,7 +777,7 @@ export function headlessReportTables(
758
777
  const entries = exportEntriesTable(section.title, section.entries);
759
778
  return Object.keys(named).length > 0 ? { ...entries, identity: named } : entries;
760
779
  }
761
- const table = exportRowsTable(section.title, displayedColumns(columnsFor(section.rows, section.columns, definition.columns)), section.rows);
780
+ const table = exportRowsTable(section.title, displayedColumns(columnsFor(section.rows, section.columns, definition.columns)), section.rows, { status: true });
762
781
  const identity = {
763
782
  ...named,
764
783
  ...(section.unit?.trim() ? { currencyOrUnit: section.unit.trim() } : {}),
@@ -776,7 +795,7 @@ export function headlessReportTables(
776
795
  }
777
796
  case "snapshot": {
778
797
  const snapshot = result as HeadlessSnapshotResult;
779
- tables = [exportRowsTable(title, displayedColumns(columnsFor(snapshot.items, undefined, definition.columns)), snapshot.items)];
798
+ tables = [exportRowsTable(title, displayedColumns(columnsFor(snapshot.items, undefined, definition.columns)), snapshot.items, { status: true })];
780
799
  break;
781
800
  }
782
801
  default: {