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
@@ -37,8 +37,8 @@ import { isNotATickerMessage } from "../not-a-ticker";
37
37
  import { isCliFailure } from "../errors";
38
38
  import { isTickerLinkPeer } from "../../layout/ticker-navigation";
39
39
  import { knownGapMessage, commandPointer, commandPointerLeads, topicPointer } from "../known-gaps";
40
- import { selectReportTables } from "../report-tables";
41
- import { cliOutputWritten } from "../result";
40
+ import { selectReportTables, type CliReportTables } from "../report-tables";
41
+ import { cliOutputWritten, type CliErrorObject } from "../result";
42
42
  import type { CatalogCommandHit } from "../unknown-command";
43
43
  import { CLI_EXIT_CODES } from "../exit-codes";
44
44
  import { shotOptionRefusal } from "./shot-options";
@@ -204,6 +204,63 @@ function foundNothing(data: { rowCount: number; unavailableSymbols: readonly str
204
204
  return data.rowCount === 0 || data.unavailableSymbols.length >= data.rowCount;
205
205
  }
206
206
 
207
+ /** A listing key as asked or reported (`BHP:XASX`, `fred:DGS10`, `SPY`), in one case. */
208
+ const listingKey = (symbol: string) => symbol.trim().toUpperCase();
209
+ const bareKey = (key: string) => key.split(":")[0]!;
210
+
211
+ /**
212
+ * The listings asked that a report has no data for. A report names one by its
213
+ * full key; one that names it by the bare symbol (`SAN` for `SAN:EPA`), or by
214
+ * the listing a bare symbol resolved to (`SXR8:XETRA` for `SXR8`), stands for
215
+ * the one listing asked that way, never for a listing asked by its own key:
216
+ * with `BHP:XASX` and `BHP` asked, `BHP:XASX` unavailable leaves `BHP`.
217
+ */
218
+ function missingListings(asked: readonly string[], unavailable: readonly string[]): Set<string> {
219
+ const keys = asked.map(listingKey);
220
+ const exact = new Set(keys);
221
+ const missing = new Set<string>();
222
+ for (const entry of unavailable.map(listingKey)) {
223
+ if (exact.has(entry)) {
224
+ missing.add(entry);
225
+ continue;
226
+ }
227
+ const loose = keys.filter((key) => bareKey(key) === bareKey(entry) && key.includes(":") !== entry.includes(":"));
228
+ if (loose.length === 1) missing.add(loose[0]!);
229
+ }
230
+ return missing;
231
+ }
232
+
233
+ /** Whether a table holds a value past its first column (a row's label or date) and its reasons: a figure, a time, any text. */
234
+ function tablesHoldValues(tables: CliReportTables): boolean {
235
+ return tables.tables.some((table) => {
236
+ const skipped = new Set(table.columns.flatMap((header, index) => index === 0 || header === "status" || header === "value_status" ? [index] : []));
237
+ return table.rows.some((row) => row.some((cell, index) => !skipped.has(index) && cell !== "" && cell != null));
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Why `fn` fails a report it printed, or null when the report has data. It
243
+ * fails when it is empty (a pane behind a wall reads nothing), when it has no
244
+ * data for any listing asked and its tables hold no value either, or when it
245
+ * is a list (`failsWithoutData`) with no name that has any. A report with data
246
+ * for some of what was asked, warnings or errors beside it, is a result: a
247
+ * loss-maker's P/E band without a P/E, or IV history with only realized
248
+ * volatility, exits 0.
249
+ */
250
+ export function reportFailure(
251
+ data: { empty: boolean; rowCount: number; symbols: readonly string[]; unavailableSymbols: readonly string[]; errors?: readonly string[]; reason?: unknown },
252
+ asked: string,
253
+ { failsWithoutData = false, tables }: { failsWithoutData?: boolean; tables?: CliReportTables } = {},
254
+ ): CliErrorObject | null {
255
+ const missing = missingListings(data.symbols, data.unavailableSymbols);
256
+ const noListing = data.symbols.length > 0 && data.symbols.every((symbol) => missing.has(listingKey(symbol)));
257
+ const noValues = tables ? !tablesHoldValues(tables) : false;
258
+ if (!data.empty && !(noListing && noValues) && !(failsWithoutData && foundNothing(data))) return null;
259
+ const reasons = [...data.errors ?? [], ...typeof data.reason === "string" ? [data.reason] : []];
260
+ const unavailable = data.unavailableSymbols.length > 0 ? `no data for ${data.unavailableSymbols.join(", ")}` : "no data";
261
+ return { code: "no_data", message: `${asked}: ${reasons.length > 0 ? reasons.join("; ") : unavailable}.`.replace(/\.\.$/, ".") };
262
+ }
263
+
207
264
  export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
208
265
  await runPaneCliCommand(ctx, async () => {
209
266
  await withPaneRuntime(ctx, args, async ({ parsed, context, registry, resolved }) => {
@@ -277,13 +334,15 @@ export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
277
334
  ? { ...report.data, glossary: glossary.map(({ term, short, definition }) => ({ term, ...(short ? { short } : {}), definition })) }
278
335
  : report.data;
279
336
  const warnings = [...parsed.listingNotes ?? [], ...await choice ?? [], ...report.data.warnings ?? []];
337
+ // A report with no data is printed as it is, and fails: exit 1, and `ok: false` in JSON with the reason.
338
+ const failure = reportFailure(report.data, ["fn", resolved.token, parsed.arg.trim()].filter(Boolean).join(" "), {
339
+ failsWithoutData: resolved.headless?.failsWithoutData, tables: report.tables });
280
340
  // The report states its own freshness in its data and text; JSON cites it as `source` too.
281
- ctx.printResult({ data, exportFreshness: report.data.freshness, ...(warnings.length > 0 ? { warnings } : {}) }, {
341
+ ctx.printResult({ data, exportFreshness: report.data.freshness, ...(warnings.length > 0 ? { warnings } : {}), ...(failure ? { failure } : {}) }, {
282
342
  text: () => glossary ? `${report.text}\n\n${renderReportGlossary(resolved.token, glossary)}` : report.text,
283
343
  ...(tabular ? { tables: selectReportTables(report.tables, resolved.tableSection) } : {}),
284
344
  });
285
- // The report is printed as it is; a list that found data for none of its names says so in the exit status too.
286
- if (resolved.headless?.failsWithoutData && foundNothing(report.data)) process.exitCode = CLI_EXIT_CODES.error;
345
+ if (failure) process.exitCode = CLI_EXIT_CODES.error;
287
346
  }, { strictHeadlessOptions: true, tableSection: true });
288
347
  });
289
348
  }
@@ -23,7 +23,9 @@ import type { MarketContext } from "../types";
23
23
  import {
24
24
  buildPaneFunctionLookup,
25
25
  buildTemplateContext,
26
+ findFunctionCode,
26
27
  nearFunctionCodes,
28
+ suggestedFunctionCodes,
27
29
  suggestPaneFunctions,
28
30
  type NearFunctionCode,
29
31
  type PaneFunctionCatalog,
@@ -36,6 +38,7 @@ import {
36
38
  optionSettings,
37
39
  type ParsedPaneFunctionArgs,
38
40
  } from "./options";
41
+ import { argumentSymbols, validateSymbolCount } from "./headless";
39
42
  import {
40
43
  capabilityHasOption,
41
44
  capabilityPaneSettings,
@@ -65,9 +68,10 @@ export interface ResolvedPaneFunction {
65
68
  }
66
69
 
67
70
  async function buildPaneInstance(
68
- resolved: Pick<ResolvedPaneFunction, "pane" | "template" | "headless" | "createOptions" | "optionSettings" | "capability" | "options">,
71
+ resolved: Pick<ResolvedPaneFunction, "token" | "pane" | "template" | "headless" | "createOptions" | "optionSettings" | "capability" | "options">,
69
72
  context: MarketContext,
70
73
  target: string,
74
+ command: "fn" | "shot",
71
75
  ): Promise<PaneInstanceConfig> {
72
76
  // Free text is a phrase, not a symbol. Treating it as a ticker makes the
73
77
  // pane look for a provider for values such as "HIGH GROWTH SOFTWARE".
@@ -88,7 +92,11 @@ async function buildPaneInstance(
88
92
  return { ...spec.settings, ...resolved.optionSettings };
89
93
  }
90
94
  const chartSpec = parseChartSpec(spec.settings?.chartSpec);
91
- if (!chartSpec) throw new Error("The chart template did not produce a valid chart specification.");
95
+ if (!chartSpec) {
96
+ // A chart of too few tickers (CMP of one) has none to draw: say how many it takes.
97
+ if (resolved.headless) validateSymbolCount(resolved.headless, resolved.token, argumentSymbols(target), command);
98
+ throw new Error("The chart template did not produce a valid chart specification.");
99
+ }
92
100
  return {
93
101
  ...spec.settings,
94
102
  chartSpec: applyChartComposerCapabilityOptions(
@@ -243,15 +251,21 @@ async function unknownPaneFunctionError(
243
251
  // NIM is one slip from NI, but a bank figure is in `fundamentals`, not in a function's statement lines.
244
252
  const bank = bankFigurePointer(name, arg);
245
253
  if (bank) return new UnknownPaneFunctionError(name, bank.details, null, bank.message);
246
- const near = nearFunctionCodes(registry, name);
254
+ // A word that asks for a function's data (IVOL for HIVG and OVDV) names them as a slip from a code names it.
255
+ const suggested = suggestedFunctionCodes(registry, name);
256
+ const near = [...suggested, ...nearFunctionCodes(registry, name).filter(({ code }) => !suggested.some((entry) => entry.code === code))];
247
257
  // A catalog that cannot be read still leaves the pointer to it.
248
258
  const closest = (await suggestPaneFunctions(registry, context, name).catch(logFailure("cli.pane-functions-resolver", "Find pane function suggestions failed", [])))
249
259
  .filter((entry) => !near.some(({ code }) => code === entry.token));
250
260
  const tokenWidth = Math.max(0, ...closest.map((entry) => entry.token.length));
251
261
  const word = /^[\w.:-]+$/.test(name) ? name : JSON.stringify(name);
252
262
  const argument = arg.trim() ? ` ${/^[\w.:,=%+@/$^-]+$/.test(arg.trim()) ? arg.trim() : JSON.stringify(arg.trim())}` : "";
263
+ // One code is run on the argument; the functions a word asks for are each.
264
+ const runs = suggested.length > 0 ? suggested : near.length === 1 ? near : [];
253
265
  return new UnknownPaneFunctionError(name, [
254
- ...near.length === 1 ? [`Run: gloomberb ${command} ${near[0]!.code}${argument}`] : [],
266
+ // A function a word asks for, typed with no argument, shows the one it takes (`<ticker>`).
267
+ ...runs.map(({ code }, index) => [index === 0 ? "Run:" : " ", "gloomberb", command, code,
268
+ argument.trim() || (suggested.length > 0 ? findFunctionCode(registry, code)?.argument : "")].filter(Boolean).join(" ")),
255
269
  ...closest.length > 0
256
270
  ? ["Closest matches:", ...closest.map((entry) => ` ${entry.token.padEnd(tokenWidth)} ${entry.label}`), `Search the catalog: gloomberb catalog ${word}`]
257
271
  : near.length > 0 ? [] : [`No function code is close to ${word}. List every function: gloomberb catalog`],
@@ -318,7 +332,7 @@ export async function resolvePaneFunction(
318
332
  options: normalizedOptions,
319
333
  ...(tableSection !== undefined ? { tableSection } : {}),
320
334
  };
321
- resolved.instance = await buildPaneInstance(resolved, context, args.arg);
335
+ resolved.instance = await buildPaneInstance(resolved, context, args.arg, resolutionSettings.command ?? "fn");
322
336
  return resolved;
323
337
  }
324
338
 
@@ -69,7 +69,8 @@ export interface CliReportEntry {
69
69
  }
70
70
 
71
71
  type Cell =
72
- | { kind: "blank" }
72
+ /** `unit`: what the column draws a number of this row in, so a missing figure still names its unit. */
73
+ | { kind: "blank"; unit?: string }
73
74
  | { kind: "text"; text: string; time?: boolean }
74
75
  | { kind: "number"; value: number; unit: string }
75
76
  /** Text that may read as a number, once the whole column is known. */
@@ -80,6 +81,8 @@ type ResolvedCell = Exclude<Cell, { kind: "textual" }>;
80
81
  interface ResolvedColumn {
81
82
  header: string;
82
83
  cells: ResolvedCell[];
84
+ /** The unit the column draws its numbers in, for a table with no rows to say it. */
85
+ unit?: string;
83
86
  }
84
87
 
85
88
  const BLANK = { kind: "blank" } as const;
@@ -265,8 +268,9 @@ function resolveColumn(header: string, cells: Cell[], parseText: boolean): Resol
265
268
  };
266
269
  }
267
270
 
271
+ /** The units a column's numbers are in, a missing figure's own among them, so the header does not change with which figures are missing. */
268
272
  function numberUnits(cells: readonly ResolvedCell[]): Set<string> {
269
- return new Set(cells.flatMap((cell) => cell.kind === "number" ? [cell.unit] : []));
273
+ return new Set(cells.flatMap((cell) => cell.kind === "number" ? [cell.unit] : cell.kind === "blank" && cell.unit != null ? [cell.unit] : []));
270
274
  }
271
275
 
272
276
  function cellValue(cell: ResolvedCell): CliReportValue {
@@ -312,8 +316,8 @@ function rowUnits(columns: readonly ResolvedColumn[], mixed: readonly boolean[],
312
316
  * metrics whose rows each have one, and otherwise a `<header> unit` column
313
317
  * beside a column that mixes them (a rate in % and a spread in bp).
314
318
  */
315
- function assembleTable(title: string, columns: readonly ResolvedColumn[], rowCount: number): CliReportTable {
316
- const units = columns.map((column) => numberUnits(column.cells));
319
+ function assembleTable(title: string, columns: readonly ResolvedColumn[], rowCount: number, status?: StatusColumn): CliReportTable {
320
+ const units = columns.map((column) => rowCount === 0 && column.unit != null ? new Set([column.unit]) : numberUnits(column.cells));
317
321
  const mixed = units.map((columnUnits) => columnUnits.size > 1);
318
322
  const byRow = mixed.some(Boolean) ? rowUnits(columns, mixed, rowCount) : null;
319
323
  const layout = columns.flatMap((column, index) => {
@@ -338,6 +342,7 @@ function assembleTable(title: string, columns: readonly ResolvedColumn[], rowCou
338
342
  },
339
343
  ];
340
344
  });
345
+ if (status) layout.push({ header: status.header, value: (row: number) => status.values[row] ?? "" });
341
346
  return {
342
347
  title,
343
348
  columns: layout.map((column) => column.header),
@@ -345,28 +350,141 @@ function assembleTable(title: string, columns: readonly ResolvedColumn[], rowCou
345
350
  };
346
351
  }
347
352
 
353
+ /** The `status` column a table of rows ends its own columns with: why a figure is missing, by row. */
354
+ interface StatusColumn {
355
+ header: string;
356
+ values: string[];
357
+ }
358
+
359
+ /**
360
+ * A number a probe draws through a column's format, to read the unit the
361
+ * column draws numbers in. A figure format rounds it; a format that prints
362
+ * it whole passes values through as text and draws no figures of its own.
363
+ */
364
+ const UNIT_PROBE = 0.1234567891;
365
+
366
+ /**
367
+ * A row with nothing in it that a format can still read through: every
368
+ * field, call and conversion of it is empty. For a format that reads fields of
369
+ * the row beyond its value and would throw on an empty one.
370
+ */
371
+ const EMPTY_ROW: unknown = (() => {
372
+ const empty: unknown = new Proxy(function empty() {}, {
373
+ get: (_target, key) => key === Symbol.toPrimitive ? (hint: string) => (hint === "number" ? Number.NaN : "") : empty,
374
+ apply: () => empty,
375
+ });
376
+ return empty;
377
+ })();
378
+
379
+ /**
380
+ * The unit a column draws a number of this row in (`%` for an IV, `""` for a
381
+ * plain figure), read from what its format draws for one; null when it draws
382
+ * no figure for one, passes the value through as it is, or throws.
383
+ */
384
+ function drawnUnit<Row>(column: CliReportColumn<Row>, row: Row): string | null {
385
+ const unit = column.unitKey ? (row as Record<string, unknown> | undefined)?.[column.unitKey] : undefined;
386
+ if (typeof unit === "string" && unit) return unit;
387
+ if (!column.format) return null;
388
+ try {
389
+ const drawn = column.format(UNIT_PROBE, row);
390
+ if (typeof drawn !== "string" || drawn.trim() === String(UNIT_PROBE)) return null;
391
+ const read = readDisplay(drawn);
392
+ return read && read !== BLANK_DISPLAY ? read.unit : null;
393
+ } catch {
394
+ // Intentional ignore: a format that cannot draw a number for this row draws no unit for it.
395
+ return null;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * The unit a column draws its figures in whatever the rows, read without one:
401
+ * on an empty row, or through a row of nothing when the format throws on an
402
+ * empty one. The same with rows and without, so a header and the table's
403
+ * status column do not depend on what was loaded. Null for a column of text.
404
+ */
405
+ function columnUnit<Row>(column: CliReportColumn<Row>): string | null {
406
+ if (column.unitKey) return "";
407
+ if (!column.format) return null;
408
+ try {
409
+ column.format(UNIT_PROBE, {} as Row);
410
+ return drawnUnit(column, {} as Row);
411
+ } catch {
412
+ // Intentional ignore: the format reads a field an empty row lacks; read it through a row of nothing.
413
+ return drawnUnit(column, EMPTY_ROW as Row);
414
+ }
415
+ }
416
+
417
+ /** What a status cell says: each reason once, after the columns it is the reason for. */
418
+ function statusText(reasons: ReadonlyArray<{ header: string; text: string }>): string {
419
+ const byText = new Map<string, string[]>();
420
+ for (const { header, text } of reasons) byText.set(text, [...byText.get(text) ?? [], header]);
421
+ return [...byText].map(([text, headers]) => `${headers.join(", ")}: ${text}`).join("; ");
422
+ }
423
+
424
+ const isMissing = (value: unknown) => value == null || (typeof value === "number" && !Number.isFinite(value));
425
+
348
426
  // Codes a spreadsheet must not turn into numbers, even when they are digits.
349
427
  const IDENTIFIER_KEY = /^(?:id|cik|code|cusip|figi|isin|symbol|ticker)$|(?:Id|Cik|Code|Cusip|Figi|Isin|Symbol|Ticker)$/;
350
428
 
351
- /** A table of rows under the columns the text view draws. */
429
+ /**
430
+ * A table of rows under the columns the text view draws. A figure that is
431
+ * missing still names the unit its column draws it in, so a header is the same
432
+ * whichever figures a symbol has. With `status`, a reason the text draws in
433
+ * place of a missing figure (`not quoted`, `under 7d`) leaves the cell empty
434
+ * and goes to the `status` column the table ends its own columns with, so a
435
+ * column of figures holds only numbers. Only a row whose own value is missing,
436
+ * in a column that draws that row's figures, has a reason: text a format draws
437
+ * from the rest of the row (a range, N/M, ATM) stays. A table has the column
438
+ * when one of its columns draws figures through a format, read from the
439
+ * columns alone so it is there with or without rows, and a table of text has
440
+ * none; beside a column of its own named Status it is `value_status`.
441
+ */
352
442
  export function exportRowsTable<Row extends Record<string, unknown>>(
353
443
  title: string,
354
444
  columns: readonly CliReportColumn<Row>[],
355
445
  rows: readonly Row[],
446
+ { status = false }: { status?: boolean } = {},
356
447
  ): CliReportTable {
357
- return assembleTable(title, columns.map((column) => resolveColumn(
358
- column.header,
359
- rows.map((row): Cell => {
360
- const record = row as Record<string, unknown> | undefined;
361
- const value = record?.[column.key];
362
- const unit = column.unitKey ? record?.[column.unitKey] : undefined;
363
- if (typeof unit === "string" && unit && typeof value === "number" && Number.isFinite(value)) {
364
- return { kind: "number", value: exportNumber(value), unit };
365
- }
366
- return exportCell(value, column.format ? column.format(value, row) : null, column.key);
367
- }),
368
- !IDENTIFIER_KEY.test(column.key),
369
- )), rows.length);
448
+ const reasons = rows.map((): Array<{ header: string; text: string }> => []);
449
+ const units = columns.map(columnUnit);
450
+ // Decided by the columns alone, so a table has the column, or not, whatever rows it has.
451
+ const withStatus = status && units.some((unit) => unit != null);
452
+ const resolved = columns.map((column, columnIndex): ResolvedColumn => {
453
+ const rowUnits = rows.map((row) => drawnUnit(column, row));
454
+ const values = rows.map((row) => (row as Record<string, unknown> | undefined)?.[column.key]);
455
+ const { header, cells } = resolveColumn(
456
+ column.header,
457
+ rows.map((row, index): Cell => {
458
+ const value = values[index];
459
+ const unit = column.unitKey ? (row as Record<string, unknown> | undefined)?.[column.unitKey] : undefined;
460
+ if (typeof unit === "string" && unit && typeof value === "number" && Number.isFinite(value)) {
461
+ return { kind: "number", value: exportNumber(value), unit };
462
+ }
463
+ return exportCell(value, column.format ? column.format(value, row) : null, column.key);
464
+ }),
465
+ !IDENTIFIER_KEY.test(column.key),
466
+ );
467
+ const table: ResolvedColumn = {
468
+ header,
469
+ cells: cells.map((cell, index): ResolvedCell => {
470
+ const unit = rowUnits[index];
471
+ const blank: ResolvedCell = unit != null ? { kind: "blank", unit } : BLANK;
472
+ if (cell.kind === "blank") return cell.unit == null ? blank : cell;
473
+ // Text drawn in place of a figure this row does not have, where the column draws this row's figures: a reason.
474
+ // Text drawn from the rest of the row (a range, N/M, ATM) is no figure's absence and stays.
475
+ if (!withStatus || cell.kind !== "text" || cell.time || unit == null || !isMissing(values[index])) return cell;
476
+ reasons[index]!.push({ header: column.header, text: cell.text });
477
+ return blank;
478
+ }),
479
+ };
480
+ const unit = units[columnIndex];
481
+ return unit == null ? table : { ...table, unit };
482
+ });
483
+ const statusColumn = withStatus ? {
484
+ header: columns.some((column) => column.header.trim().toLowerCase() === "status") ? "value_status" : "status",
485
+ values: reasons.map(statusText),
486
+ } : undefined;
487
+ return assembleTable(title, resolved, rows.length, statusColumn);
370
488
  }
371
489
 
372
490
  /** Label/value lines as `Metric,Value`; a number's unit moves into its label. */
package/src/cli/result.ts CHANGED
@@ -46,6 +46,12 @@ export interface CliResult<T = unknown> {
46
46
  * `fear-greed` prints its row alone.
47
47
  */
48
48
  exportFreshness?: ReportFreshness;
49
+ /**
50
+ * Why a result that still prints counts as a failure (a report with no
51
+ * data): the command exits 1, and JSON says `ok: false` with this as
52
+ * `error` beside the data, so the envelope and the exit status agree.
53
+ */
54
+ failure?: CliErrorObject;
49
55
  }
50
56
 
51
57
  export interface CliErrorObject {
@@ -110,8 +116,9 @@ export interface CliResultRenderOptions<T = unknown, Row = Record<string, unknow
110
116
  exportNotes?: readonly string[];
111
117
  }
112
118
 
113
- interface CliResultJsonEnvelope<T> extends Omit<CliResult<T>, "freshness" | "exportFreshness"> {
114
- ok: true;
119
+ interface CliResultJsonEnvelope<T> extends Omit<CliResult<T>, "freshness" | "exportFreshness" | "failure"> {
120
+ ok: boolean;
121
+ error?: CliErrorObject;
115
122
  /** Where the data comes from, as CSV's closing `#` lines say it. */
116
123
  source?: ExportSource;
117
124
  columns?: Array<Pick<CliResultColumn, "key" | "header" | "align" | "width">>;
@@ -398,14 +405,15 @@ export function serializeCliResult<T, Row extends Record<string, unknown> = Reco
398
405
  options: CliGlobalOptions,
399
406
  renderOptions: CliResultRenderOptions<T, Row> = {},
400
407
  ): string {
401
- const { freshness, exportFreshness, ...rest } = result;
408
+ const { freshness, exportFreshness, failure, ...rest } = result;
402
409
  const cited = freshness ?? exportFreshness;
403
410
  if (options.format === "json") {
404
411
  const columns = serializeColumns(renderOptions.columns);
405
412
  const window = Array.isArray(result.data) ? windowed(result.data as unknown[], options, renderOptions) : null;
406
413
  const cut = window ? cutMetadata(window) : null;
407
414
  const envelope: CliResultJsonEnvelope<T> = {
408
- ok: true,
415
+ ok: !failure,
416
+ ...(failure ? { error: failure } : {}),
409
417
  ...rest,
410
418
  ...(freshness || cut ? { metadata: { ...rest.metadata, ...(freshness ? { freshness } : {}), ...(cut ? { rows: cut } : {}) } } : {}),
411
419
  data: window ? window.rows as T : result.data,
@@ -4,6 +4,8 @@ import { splitSymbolArgs, splitSymbolWords, stripStrayEdges, uniqueSymbols } fro
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"]);
7
+ /** A header naming a column of company names, read when no column names the symbols. */
8
+ const NAME_HEADERS = new Set(["name", "names", "company", "companies", "companyname", "security", "securityname", "issuer", "issuername"]);
7
9
 
8
10
  /** One symbol as it can be typed: AAPL, $nvda, TTF.AS, SAN:EPA, BRK-B, 7203.T, ^GSPC, ES=F. */
9
11
  const SYMBOL_TOKEN = /^\$?[\p{L}\p{N}^][\p{L}\p{N}.:\-^=/&]{0,23}$/u;
@@ -82,17 +84,32 @@ interface SymbolText {
82
84
  symbols: string[];
83
85
  /** Cells beside the symbol column of a table with no header, in reading order; empty for a list or a CSV with a header. */
84
86
  ignored: string[];
87
+ /**
88
+ * The symbols are a list's entries, each a line or a cell of a list split
89
+ * by semicolons or commas, and are read as typed entries are
90
+ * (`readNameEntries`): one with spaces is a company's name (`Altria
91
+ * Group`), or symbols written on one line (`AAPL MSFT`).
92
+ */
93
+ entries?: true;
85
94
  }
86
95
 
87
96
  /**
88
97
  * The symbols in a file or piped text, in order. A spreadsheet export reads
89
- * from the column its header names (Symbol, Ticker, Code). Without a header,
98
+ * from the column its header names (Symbol, Ticker, Code), else from its
99
+ * Name or Company column, as `entries`. Without a header,
90
100
  * every cell is a symbol unless some column after the first holds a name, a
91
101
  * price or a weight: that table is read by its first column, and the cells
92
102
  * beside it are returned as `ignored` so the command can name them. A list
93
103
  * split by commas, spaces or newlines gives every entry, even when a line of
94
- * it is ragged or carries a stray separator (`"BIMAS ,"`). A byte order mark,
95
- * Windows line ends, blank lines and lines starting with # or // are ignored.
104
+ * it is ragged or carries a stray separator (`"BIMAS ,"`). A list of lines
105
+ * gives each line whole, and a list of names split by semicolons or commas
106
+ * (one line, or one name a line) each cell, as `entries`: a line with spaces
107
+ * is a company's name (`Altria Group`) or symbols on one line, which reading
108
+ * the entries tells apart. Names beside other text (`Apple Inc,Technology`)
109
+ * are a table read by its first column, its names as `entries`. A list split
110
+ * by spaces with figures beside the symbols (`AAPL 150.00`) is a table. A
111
+ * byte order mark, Windows line ends, blank lines and lines starting with #
112
+ * or // are ignored.
96
113
  */
97
114
  export function parseSymbolText(text: string): SymbolText {
98
115
  const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
@@ -105,13 +122,27 @@ export function parseSymbolText(text: string): SymbolText {
105
122
  const exact = header.findIndex((cell) => SYMBOL_HEADERS.has(cell));
106
123
  const column = exact >= 0 ? exact : header.findIndex((cell) => cell.includes("symbol") || cell.includes("ticker"));
107
124
  if (column >= 0) return { symbols: records.slice(1).map((cells) => cells[column] ?? "").filter(Boolean), ignored: [] };
125
+ // Without one, a delimited export's Name or Company column holds the companies, read as typed names are.
126
+ const nameColumn = delimiter ? header.findIndex((cell) => NAME_HEADERS.has(cell)) : -1;
127
+ if (nameColumn >= 0) return { symbols: records.slice(1).map((cells) => cells[nameColumn] ?? "").filter(Boolean), ignored: [], entries: true };
108
128
  // A separator or quote mark stuck to a cell is dropped (and named) when it is read, so it does not make the list a table.
109
129
  const cleaned = records.map((cells) => cells.map((cell) => stripStrayEdges(cell).text));
130
+ const figures = cleaned.some((cells) => cells.some((cell, index) => index > 0 && NUMBER_CELL.test(cell) && /\d/.test(cell)));
131
+ // Lines split by spaces alone are entries, unless figures sit beside the symbols.
132
+ if (!delimiter && !figures) return { symbols: records.map((cells) => cells.join(" ")), ignored: [], entries: true };
110
133
  const grid = cleaned.every((cells) => cells.every((cell, index) => !cell || (looksLikeSymbol(cell) && (index === 0 || !NUMBER_CELL.test(cell)))));
111
134
  if (grid) return { symbols: records.flat().filter(Boolean), ignored: [] };
135
+ // Names split by semicolons or commas, with no figures: one pasted line, or one name a line with a separator left
136
+ // on it, is a list. A name beside a sector or any other text is a table, read by its first column below.
137
+ const names = cleaned.some((cells) => /\s/.test(cells[0] ?? ""));
138
+ if (!figures && (records.length === 1 || (names && cleaned.every((cells) => cells.filter(Boolean).length <= 1)))) {
139
+ return { symbols: records.flat().filter(Boolean), ignored: [], entries: true };
140
+ }
112
141
  return {
113
142
  symbols: records.map((cells) => cells[0] ?? "").filter(Boolean),
114
143
  ignored: records.flatMap((cells) => cells.slice(1)).filter(Boolean),
144
+ // A first column of names (Apple Inc) is read as typed names are.
145
+ ...names ? { entries: true as const } : {},
115
146
  };
116
147
  }
117
148
 
@@ -151,8 +182,10 @@ export interface SymbolListSource {
151
182
  stdinPiped?: boolean;
152
183
  /**
153
184
  * Reads the typed entries (`splitSymbolArgs`) as symbols, such as
154
- * `readNameEntries`, which keeps a company's name whole. Without it every
155
- * entry is its words, each a symbol.
185
+ * `readNameEntries`, which keeps a company's name whole, and then the
186
+ * entries of each list a file or standard input holds. Called first for
187
+ * what was typed, even when that is nothing. Without it every entry is its
188
+ * words, each a symbol.
156
189
  */
157
190
  readEntries?: (entries: string[]) => Promise<string[]>;
158
191
  }
@@ -176,17 +209,20 @@ export async function readSymbolList(source: SymbolListSource): Promise<{ symbol
176
209
  const readStdin = source.readStdin ?? (() => Bun.stdin.text());
177
210
  const wantsStdin = source.args.includes("-") || source.file === "-";
178
211
  const typedArgs = source.args.filter((arg) => arg !== "-");
212
+ const readEntries = (entries: string[]) => source.readEntries ? source.readEntries(entries) : Promise.resolve(splitSymbolWords(entries));
179
213
  const typed = source.readEntries ? await source.readEntries(splitSymbolArgs(typedArgs)) : splitSymbolWords(typedArgs);
180
214
  const collected = [...typed];
181
215
  // A separator left on the end of what was typed: `UPST;`, or a list that ends in a comma.
182
216
  const strays = new Set<string>(stripStrayEdges(typedArgs.join(" ")).notes);
183
217
  const ignored: string[] = [];
184
- const addCells = ({ symbols, ignored: beside }: SymbolText) => {
185
- for (const cell of symbols) {
218
+ const addCells = async ({ symbols, ignored: beside, entries }: SymbolText) => {
219
+ const cells = symbols.map((cell) => {
186
220
  const cleaned = stripStrayEdges(cell);
187
221
  cleaned.notes.forEach((note) => strays.add(note));
188
- collected.push(cleaned.text);
189
- }
222
+ return cleaned.text;
223
+ }).filter(Boolean);
224
+ // A list's lines and names are read as typed entries are: `Altria Group` stays one company.
225
+ collected.push(...entries ? await readEntries(cells) : cells);
190
226
  ignored.push(...beside);
191
227
  };
192
228
  if (source.file != null && source.file !== "-") {
@@ -198,10 +234,10 @@ export async function readSymbolList(source: SymbolListSource): Promise<{ symbol
198
234
  const code = (error as NodeJS.ErrnoException).code;
199
235
  throw new SymbolListError(code === "ENOENT" ? `No file at ${source.file}.` : `Could not read ${source.file}: ${error instanceof Error ? error.message : String(error)}`);
200
236
  }
201
- addCells(parseSymbolText(text));
237
+ await addCells(parseSymbolText(text));
202
238
  }
203
239
  if (wantsStdin || (typed.length === 0 && source.file == null && (source.stdinPiped ?? stdinIsPiped()))) {
204
- addCells(parseSymbolText(await readStdin()));
240
+ await addCells(parseSymbolText(await readStdin()));
205
241
  }
206
242
  const ignoredNote = ignoredCellsNote(ignored);
207
243
  return { ...uniqueSymbols(collected), notes: [...strays, ...ignoredNote ? [ignoredNote] : []] };
@@ -145,6 +145,7 @@ function ThemedCompositeChart({
145
145
  onCursorDateChange,
146
146
  onViewportChange,
147
147
  onActivate,
148
+ onPress,
148
149
  onToggleSeries,
149
150
  isSeriesToggleable,
150
151
  timePick,
@@ -527,6 +528,14 @@ function ThemedCompositeChart({
527
528
  removeDrawing,
528
529
  pickDrawColor,
529
530
  });
531
+ const pressFromPointer = useCallback(() => {
532
+ onPress?.();
533
+ activateFromPointer();
534
+ }, [activateFromPointer, onPress]);
535
+ const pressPlot = useCallback(() => {
536
+ cancelKeyboardPlacement();
537
+ onPress?.();
538
+ }, [cancelKeyboardPlacement, onPress]);
530
539
  const handleEmptyMouseScroll = useCallback((event: ChartMouseEvent) => {
531
540
  const scroll = event.scroll;
532
541
  if (!interactive || !navigationFrame || !scroll) return;
@@ -597,7 +606,7 @@ function ThemedCompositeChart({
597
606
  accessoryWidth={legendAccessoryWidth}
598
607
  formatValue={formatValue}
599
608
  showLatestChangePercent={showLatestChangePercent}
600
- onActivate={activateFromPointer}
609
+ onActivate={pressFromPointer}
601
610
  onToggleSeries={onToggleSeries}
602
611
  isSeriesToggleable={isSeriesToggleable}
603
612
  keyboardIndex={legendKeyboardIndex}
@@ -716,7 +725,7 @@ function ThemedCompositeChart({
716
725
  accessoryWidth={legendAccessoryWidth}
717
726
  formatValue={formatValue}
718
727
  showLatestChangePercent={showLatestChangePercent}
719
- onActivate={activateFromPointer}
728
+ onActivate={pressFromPointer}
720
729
  onToggleSeries={onToggleSeries}
721
730
  isSeriesToggleable={isSeriesToggleable}
722
731
  keyboardIndex={legendKeyboardIndex}
@@ -734,11 +743,11 @@ function ThemedCompositeChart({
734
743
  showColors={isDrawingTool(armedTool) || !!selectedDrawingId}
735
744
  levelTool={levelsEditable}
736
745
  onArmTool={(tool) => {
737
- activateFromPointer();
746
+ pressFromPointer();
738
747
  armTool(tool);
739
748
  }}
740
749
  onPickColor={(color) => {
741
- activateFromPointer();
750
+ pressFromPointer();
742
751
  pickDrawColor(color);
743
752
  }}
744
753
  />
@@ -782,7 +791,7 @@ function ThemedCompositeChart({
782
791
  onToolSpanChange={setToolSpan}
783
792
  onDragActiveChange={setPointerDragging}
784
793
  keyboardToolDrag={keyboardToolDrag?.panelId === panel.id ? keyboardToolDrag : null}
785
- onPointerPress={cancelKeyboardPlacement}
794
+ onPointerPress={pressPlot}
786
795
  showTextFallback={showTextFallback}
787
796
  onPickTime={timePick?.onPick}
788
797
  levels={levelHost?.panel.id === panel.id ? levels ?? null : null}