gloomberb 0.16.2 → 0.16.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 (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
@@ -10,8 +10,16 @@ import {
10
10
  type ScreenerCategory,
11
11
  } from "../../plugins/builtin/market-movers/screener";
12
12
  import { formatMoverPrice, moverReferencePrice } from "../../plugins/builtin/market-movers/model";
13
- import { loadCalendar, matchesCountry, matchesImpact, type CountryFilter, type ImpactFilter } from "../../plugins/builtin/econ/calendar-model";
14
- import { isoDate, requireArg, takeOption } from "./command-utils";
13
+ import {
14
+ COUNTRY_CYCLE,
15
+ FILTER_CYCLE,
16
+ loadCalendar,
17
+ matchesCountry,
18
+ matchesImpact,
19
+ type CountryFilter,
20
+ type ImpactFilter,
21
+ } from "../../plugins/builtin/econ/calendar-model";
22
+ import { isoDate, rejectExtraArgs, requireArg, requireOneArg, takeOption } from "./command-utils";
15
23
  import { buildCorrelationSeries } from "../../plugins/builtin/correlation/matrix/model";
16
24
  import { alignDailyCloses, correlateDailyCloses } from "../../plugins/builtin/correlation/compute";
17
25
  import { mixedSessionCloseNote } from "../../market-data/market/session-close-note";
@@ -48,11 +56,26 @@ const MOVER_COLUMNS = [
48
56
  ];
49
57
  const START_OPTION = { flags: "--start <yyyy-mm-dd>", description: "First observation date (default 2021-01-01)" };
50
58
 
51
- function screenerCategory(value: string | undefined): ScreenerCategory | "trending" {
52
- if (value === "losers") return "day_losers";
53
- if (value === "active" || value === "most-active") return "most_actives";
54
- if (value === "trending") return "trending";
55
- return "day_gainers";
59
+ const MOVER_LISTS: Record<string, ScreenerCategory | "trending"> = {
60
+ gainers: "day_gainers",
61
+ losers: "day_losers",
62
+ active: "most_actives",
63
+ "most-active": "most_actives",
64
+ trending: "trending",
65
+ };
66
+ const MOVERS_USAGE = "movers [gainers|losers|active|trending]";
67
+
68
+ function screenerCategory(args: readonly string[], ctx: Parameters<CliCommandDef["execute"]>[1]): ScreenerCategory | "trending" {
69
+ rejectExtraArgs(args, 1, { usage: MOVERS_USAGE, takes: "one list" }, ctx);
70
+ const list = args[0]?.toLowerCase() ?? "gainers";
71
+ const category = MOVER_LISTS[list];
72
+ if (!category) ctx.fail(`Unknown list "${args[0]}".`, "Use gainers, losers, active or trending.");
73
+ return category;
74
+ }
75
+
76
+ /** A command that takes no arguments fails on one, rather than answering as if it were not there. */
77
+ function rejectArgs(args: readonly string[], usage: string, ctx: Parameters<CliCommandDef["execute"]>[1], advice?: string): void {
78
+ rejectExtraArgs(args, 0, { usage, takes: "no arguments", advice }, ctx);
56
79
  }
57
80
 
58
81
  function quoteRows(results: Awaited<ReturnType<NonNullable<import("../../types/data-provider").AssetDataProvider["getQuotesBatch"]>>>) {
@@ -72,7 +95,7 @@ function quoteRows(results: Awaited<ReturnType<NonNullable<import("../../types/d
72
95
  }
73
96
 
74
97
  async function runMoverCommand(args: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
75
- const category = screenerCategory(args[0]);
98
+ const category = screenerCategory(args, ctx);
76
99
  const limit = ctx.cliOptions.limit ?? 25;
77
100
  if (category === "trending") {
78
101
  await withCliServices(ctx, async (services) => {
@@ -126,10 +149,21 @@ function utcDateTimePart(value: unknown, part: "date" | "time"): string {
126
149
  return part === "date" ? iso.slice(0, 10) : `${iso.slice(11, 16)} UTC`;
127
150
  }
128
151
 
152
+ const ECON_USAGE = "econ [--country <region>] [--impact <level>]";
153
+
154
+ /** One of a filter's values, matched without regard to case; anything else fails with the list. */
155
+ function parseFilter<T extends string>(value: string | undefined, values: readonly T[], label: string, ctx: Parameters<CliCommandDef["execute"]>[1]): T {
156
+ if (value == null) return values[0]!;
157
+ const match = values.find((candidate) => candidate.toLowerCase() === value.trim().toLowerCase());
158
+ if (!match) ctx.fail(`Unknown ${label} "${value}".`, `Use ${values.join(", ")}.`);
159
+ return match;
160
+ }
161
+
129
162
  async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
130
163
  const rawArgs = [...args];
131
- const country = (takeOption(rawArgs, "--country") ?? "all") as CountryFilter;
132
- const impact = (takeOption(rawArgs, "--impact") ?? "all") as ImpactFilter;
164
+ const country: CountryFilter = parseFilter(takeOption(rawArgs, "--country"), COUNTRY_CYCLE, "country", ctx);
165
+ const impact: ImpactFilter = parseFilter(takeOption(rawArgs, "--impact"), FILTER_CYCLE, "impact", ctx);
166
+ rejectArgs(rawArgs, ECON_USAGE, ctx, "Filter with --country and --impact.");
133
167
  await withCliServices(ctx, async (services) => {
134
168
  const { data: events } = await loadCalendar(ctx.cliOptions.refresh);
135
169
  const rows = events
@@ -170,7 +204,7 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
170
204
  const args = [...rawArgs];
171
205
  const startDate = takeOption(args, "--start") ?? "2021-01-01";
172
206
  const sortOrder = (takeOption(args, "--sort") ?? "desc") as "asc" | "desc";
173
- const seriesId = requireArg(args[0]?.toUpperCase(), "Usage: gloomberb fred <series-id> [--start <yyyy-mm-dd>]", ctx);
207
+ const seriesId = requireOneArg(args, "fred <series-id> [--start <yyyy-mm-dd>]", "series", ctx).toUpperCase();
174
208
  const data = await apiClient.getCloudFredSeries(seriesId, { startDate, sortOrder });
175
209
  const rows = data.observations.slice(0, ctx.cliOptions.limit ?? data.observations.length);
176
210
  ctx.printResult({
@@ -187,8 +221,10 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
187
221
 
188
222
  const YIELD_TENORS: Record<string, string> = { DGS3MO: "3M", DGS2: "2Y", DGS10: "10Y", DGS30: "30Y" };
189
223
 
190
- async function runYieldCurve(args: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
224
+ async function runYieldCurve(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
225
+ const args = [...rawArgs];
191
226
  const startDate = takeOption(args, "--start") ?? "2021-01-01";
227
+ rejectArgs(args, "yield-curve [--start <yyyy-mm-dd>]", ctx);
192
228
  const series = Object.keys(YIELD_TENORS);
193
229
  const results = await Promise.all(series.map(async (seriesId) => {
194
230
  const data = await apiClient.getCloudFredSeries(seriesId, { startDate, sortOrder: "desc" });
@@ -217,9 +253,10 @@ async function runYieldCurve(args: string[], ctx: Parameters<CliCommandDef["exec
217
253
  async function runCorrelation(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
218
254
  const args = [...rawArgs];
219
255
  const exchange = takeOption(args, "--exchange");
220
- const usage = "Usage: gloomberb correlation <symbol-a> <symbol-b>";
221
- requireArg(args[0], usage, ctx);
222
- requireArg(args[1], usage, ctx);
256
+ const usage = "correlation <symbol-a> <symbol-b>";
257
+ requireArg(args[0], `Usage: gloomberb ${usage}`, ctx);
258
+ requireArg(args[1], `Usage: gloomberb ${usage}`, ctx);
259
+ rejectExtraArgs(args, 2, { usage, takes: "two symbols", advice: "Run it once per pair." }, ctx);
223
260
  await withCliServices(ctx, async (services) => {
224
261
  // --exchange is for a symbol that names no exchange of its own.
225
262
  const [leftListing, rightListing] = await Promise.all([args[0]!, args[1]!].map((raw) => (
@@ -274,7 +311,7 @@ export const overviewCliCommands: CliCommandDef[] = [
274
311
  description: "Show gainers, losers, most active, or trending stocks",
275
312
  help: {
276
313
  group: CLI_COMMAND_GROUPS.markets,
277
- usage: ["movers [gainers|losers|active|trending]"],
314
+ usage: [MOVERS_USAGE],
278
315
  examples: ["movers", "movers losers --limit 10", "movers trending"],
279
316
  },
280
317
  execute: runMoverCommand,
@@ -283,20 +320,26 @@ export const overviewCliCommands: CliCommandDef[] = [
283
320
  name: "indices",
284
321
  description: "Show the major US stock indices",
285
322
  help: { group: CLI_COMMAND_GROUPS.markets, usage: ["indices"] },
286
- execute: (_args, ctx) => runQuoteBasket([...MARKET_SUMMARY_SYMBOLS], ctx, { group: "indices" }),
323
+ execute: (args, ctx) => {
324
+ rejectArgs(args, "indices", ctx);
325
+ return runQuoteBasket([...MARKET_SUMMARY_SYMBOLS], ctx, { group: "indices" });
326
+ },
287
327
  },
288
328
  {
289
329
  name: "sectors",
290
330
  description: "Show the SPDR sector ETFs",
291
331
  help: { group: CLI_COMMAND_GROUPS.markets, usage: ["sectors"] },
292
- execute: (_args, ctx) => runQuoteBasket(getSectorCollection("sectors").items.map((item) => item.etf), ctx, { group: "sectors" }),
332
+ execute: (args, ctx) => {
333
+ rejectArgs(args, "sectors", ctx);
334
+ return runQuoteBasket(getSectorCollection("sectors").items.map((item) => item.etf), ctx, { group: "sectors" });
335
+ },
293
336
  },
294
337
  {
295
338
  name: "econ",
296
339
  description: "List upcoming economic calendar events",
297
340
  help: {
298
341
  group: CLI_COMMAND_GROUPS.markets,
299
- usage: ["econ [--country <region>] [--impact <level>]"],
342
+ usage: [ECON_USAGE],
300
343
  options: [
301
344
  { flags: "--country <region>", description: "US, G7, EU, or all (default all)" },
302
345
  { flags: "--impact <level>", description: "high, medium, low, or all (default all)" },
@@ -1,5 +1,6 @@
1
1
  import { formatShortDate, parseDisplayDate } from "../../utils/datetime-format";
2
2
  import { formatReportedMoney } from "../../utils/reported-money";
3
+ import { fundamentalsCurrency, reportedEnterpriseValue } from "../../utils/fundamentals";
3
4
  import { latestFinancialPeriod } from "../../utils/latest-financial-period";
4
5
  import { exportedFundamentals, formatPriceEarnings, priceEarningsOnEarnings } from "../../utils/price-earnings";
5
6
  import { describeFundamentalMarketCap, selectMarketCapitalization } from "../../utils/market-capitalization";
@@ -51,8 +52,10 @@ import { NotesFiles } from "../../plugins/builtin/notes/files";
51
52
  import { isUsEquityTicker } from "../../utils/sec";
52
53
  import { canonicalExchange, exchangeLabel, isKnownExchangeCode } from "../../utils/exchanges";
53
54
  import { failIfNotTraded, ListingArgError, listingIdentity, resolveCliListing, type CliListing } from "../listing-arg";
54
- import { isDepositaryReceipt } from "../../utils/depositary-receipt";
55
+ import { sharesOutstandingInReceipts } from "../../utils/depositary-receipt";
55
56
  import { cliFreshnessFooter } from "../result";
57
+ import { exportEntriesTable, reportFooterLines, type CliReportTables } from "../report-tables";
58
+ import type { ReportFreshness } from "../pane-functions/freshness";
56
59
  import { fundamentalsFreshness, quotesFreshness } from "../freshness";
57
60
 
58
61
  const NEWS_ITEM_LIMIT = 5;
@@ -99,10 +102,26 @@ function buildStatementMetrics(statement: FinancialStatement, currency?: string)
99
102
  ["Total Debt", money(statement.totalDebt)],
100
103
  ["Equity", money(statement.totalEquity)],
101
104
  ["Diluted EPS", money(statement.eps, true)],
102
- ["Diluted Shares", formatCompact(statement.dilutedShares)],
105
+ // Labelled only when the service says the row counts receipts.
106
+ ["Diluted Shares", statement.dilutedShares != null && statement.shareBasis === "depositary_receipt"
107
+ ? `${formatCompact(statement.dilutedShares)} (ADR equivalent)`
108
+ : formatCompact(statement.dilutedShares)],
103
109
  ];
104
110
  }
105
111
 
112
+ /**
113
+ * A receipt's count is its ordinary shares expressed in receipts, as its
114
+ * market cap is; the ordinary count follows when the service gives it.
115
+ */
116
+ function sharesOutstandingText(fundamentals: TickerFinancials["fundamentals"], inReceipts: boolean): string {
117
+ const shares = fundamentals?.sharesOutstanding;
118
+ if (shares == null || !inReceipts) return formatCompact(shares);
119
+ const ordinary = fundamentals?.shareBasis === "depositary_receipt" ? fundamentals.underlyingOrdinaryShares : undefined;
120
+ return ordinary != null && Number.isFinite(ordinary) && ordinary > 0
121
+ ? `${formatCompact(shares)} (ADR equivalent = ${formatCompact(ordinary)} ordinary shares)`
122
+ : `${formatCompact(shares)} (ADR equivalent)`;
123
+ }
124
+
106
125
  /** Provider prose is wrapped; `verbatim` keeps the user's own spacing, such as a table in a note. */
107
126
  function appendTextSection(lines: string[], title: string, content: string | undefined, verbatim = false) {
108
127
  const text = content?.trim();
@@ -241,60 +260,103 @@ function enterpriseValueCurrency(
241
260
  || undefined;
242
261
  }
243
262
 
263
+ /** A fundamentals line: the text the report prints, and for CSV the figure behind it in `unit`. */
264
+ interface FundamentalsMetric {
265
+ label: string;
266
+ text: string;
267
+ value?: number | string | null;
268
+ unit?: string;
269
+ }
270
+
271
+ type MetricFigure = Omit<FundamentalsMetric, "label">;
272
+
273
+ /** An amount in the currency the source reported it in; `ccy?` when it named none, as the text says. */
274
+ function reportedMoney(value: number | undefined, currency: string | undefined, perShare = false): MetricFigure {
275
+ return { text: formatReportedMoney(value, currency, perShare), value, unit: currency?.trim() || "ccy?" };
276
+ }
277
+
278
+ function percentFigure(fraction: number | null | undefined, format: (value: number) => string): MetricFigure {
279
+ return fraction != null ? { text: format(fraction), value: fraction * 100, unit: "%" } : { text: "—" };
280
+ }
281
+
282
+ function multipleFigure(value: ReturnType<typeof priceEarningsOnEarnings> | number | undefined): MetricFigure {
283
+ const text = formatPriceEarnings(value, 2);
284
+ return { text, value: typeof value === "number" && Number.isFinite(value) && value > 0 ? value : text === "N/M" ? text : null };
285
+ }
286
+
244
287
  function fundamentalsMetrics(
245
288
  quote: TickerFinancials["quote"],
246
289
  fundamentals: TickerFinancials["fundamentals"],
247
290
  profile: TickerFinancials["profile"],
248
- marketCapText: string,
291
+ marketCap: MetricFigure,
249
292
  priceReturns: { return1Y?: number | null; return3Y?: number | null },
250
- enterpriseValueText = formatReportedMoney(fundamentals?.enterpriseValue, enterpriseValueCurrency(quote, fundamentals)),
251
- ): Array<[string, string]> {
293
+ reportingCurrency: string | undefined,
294
+ enterpriseValue = reportedMoney(reportedEnterpriseValue(fundamentals), enterpriseValueCurrency(quote, fundamentals)),
295
+ ): FundamentalsMetric[] {
296
+ const signed = (value: number) => colorBySign(formatPercent(value), value);
297
+ const receipt = fundamentals?.sharesOutstanding != null && sharesOutstandingInReceipts(quote, fundamentals, profile?.description);
252
298
  return [
253
- ["Market Cap", marketCapText],
254
- ["Enterprise Value", enterpriseValueText],
299
+ { label: "Market Cap", ...marketCap },
300
+ { label: "Enterprise Value", ...enterpriseValue },
255
301
  // A multiple over a loss is N/M, even beside a positive one the source served from an older period.
256
- ["P/E (TTM)", formatPriceEarnings(priceEarningsOnEarnings(fundamentals?.trailingPE, fundamentals?.eps), 2)],
257
- ["Forward P/E", formatPriceEarnings(priceEarningsOnEarnings(fundamentals?.forwardPE, fundamentals?.forwardEps), 2)],
258
- ["PEG", formatPriceEarnings(fundamentals?.pegRatio, 2)],
302
+ { label: "P/E (TTM)", ...multipleFigure(priceEarningsOnEarnings(fundamentals?.trailingPE, fundamentals?.eps)) },
303
+ { label: "Forward P/E", ...multipleFigure(priceEarningsOnEarnings(fundamentals?.forwardPE, fundamentals?.forwardEps)) },
304
+ { label: "PEG", ...multipleFigure(fundamentals?.pegRatio) },
259
305
  // The flows and EPS are one trailing-twelve-month block, the same twelve months for each line.
260
- ["EPS (TTM)", formatReportedMoney(fundamentals?.eps, fundamentals?.financialCurrency, true)],
261
- [`Dividend Yield${fundamentals?.dividendYieldBasis ? ` (${fundamentals.dividendYieldBasis})` : ""}`, fundamentals?.dividendYield != null ? formatFractionPercentCell(fundamentals.dividendYield) : "—"],
262
- ["Revenue (TTM)", formatReportedMoney(fundamentals?.revenue, fundamentals?.financialCurrency)],
263
- ["Net Income (TTM)", formatReportedMoney(fundamentals?.netIncome, fundamentals?.financialCurrency)],
264
- ["Operating Cash Flow (TTM)", formatReportedMoney(fundamentals?.operatingCashFlow, fundamentals?.financialCurrency)],
265
- ["Free Cash Flow (TTM)", formatReportedMoney(fundamentals?.freeCashFlow, fundamentals?.financialCurrency)],
306
+ { label: "EPS (TTM)", ...reportedMoney(fundamentals?.eps, reportingCurrency, true) },
307
+ {
308
+ label: `Dividend Yield${fundamentals?.dividendYieldBasis ? ` (${fundamentals.dividendYieldBasis})` : ""}`,
309
+ ...percentFigure(fundamentals?.dividendYield, formatFractionPercentCell),
310
+ },
311
+ { label: "Revenue (TTM)", ...reportedMoney(fundamentals?.revenue, reportingCurrency) },
312
+ { label: "Net Income (TTM)", ...reportedMoney(fundamentals?.netIncome, reportingCurrency) },
313
+ { label: "Operating Cash Flow (TTM)", ...reportedMoney(fundamentals?.operatingCashFlow, reportingCurrency) },
314
+ { label: "Free Cash Flow (TTM)", ...reportedMoney(fundamentals?.freeCashFlow, reportingCurrency) },
266
315
  // Levels, not changes, so they carry no sign.
267
- ["Operating Margin", fundamentals?.operatingMargin != null ? formatFractionPercentCell(fundamentals.operatingMargin) : "—"],
268
- ["Profit Margin", fundamentals?.profitMargin != null ? formatFractionPercentCell(fundamentals.profitMargin) : "—"],
269
- ["Revenue Growth", fundamentals?.revenueGrowth != null ? colorBySign(formatPercent(fundamentals.revenueGrowth), fundamentals.revenueGrowth) : "—"],
270
- ["Last Quarter Growth", fundamentals?.lastQuarterGrowth != null ? colorBySign(formatPercent(fundamentals.lastQuarterGrowth), fundamentals.lastQuarterGrowth) : "—"],
271
- ["1Y Return", priceReturns.return1Y != null ? colorBySign(formatPercent(priceReturns.return1Y), priceReturns.return1Y) : "—"],
272
- ["3Y Return", priceReturns.return3Y != null ? colorBySign(formatPercent(priceReturns.return3Y), priceReturns.return3Y) : "—"],
273
- // A receipt's count is its ordinary shares expressed in receipts, as its market cap is.
274
- [
275
- "Shares Outstanding",
276
- fundamentals?.sharesOutstanding != null && isDepositaryReceipt({ ...quote, description: profile?.description })
277
- ? `${formatCompact(fundamentals.sharesOutstanding)} (ADR equivalent)`
278
- : formatCompact(fundamentals?.sharesOutstanding),
279
- ],
316
+ { label: "Operating Margin", ...percentFigure(fundamentals?.operatingMargin, formatFractionPercentCell) },
317
+ { label: "Profit Margin", ...percentFigure(fundamentals?.profitMargin, formatFractionPercentCell) },
318
+ { label: "Revenue Growth", ...percentFigure(fundamentals?.revenueGrowth, signed) },
319
+ { label: "Last Quarter Growth", ...percentFigure(fundamentals?.lastQuarterGrowth, signed) },
320
+ { label: "1Y Return", ...percentFigure(priceReturns.return1Y, signed) },
321
+ { label: "3Y Return", ...percentFigure(priceReturns.return3Y, signed) },
322
+ {
323
+ label: "Shares Outstanding",
324
+ text: sharesOutstandingText(fundamentals, receipt),
325
+ value: fundamentals?.sharesOutstanding,
326
+ ...(receipt ? { unit: "ADR equivalent" } : {}),
327
+ },
280
328
  ];
281
329
  }
282
330
 
331
+ function metricLines(metrics: readonly FundamentalsMetric[]): Array<[string, string]> {
332
+ return metrics.map((metric) => [metric.label, metric.text]);
333
+ }
334
+
283
335
  const VALUATION_METRICS = new Set(["Market Cap", "Enterprise Value", "P/E (TTM)", "Forward P/E", "PEG", "EPS (TTM)"]);
284
336
 
337
+ type FundamentalsReportData = TickerFinancials & { symbol: string; exchange?: string };
338
+
339
+ /** The lines `fundamentals` or `valuation` reports, the ones with a value. */
340
+ function fundamentalsReportMetrics(financials: FundamentalsReportData, view: "fundamentals" | "valuation"): FundamentalsMetric[] {
341
+ const quote = financials.quote;
342
+ const fundamentals = financials.fundamentals;
343
+ const capitalization = selectMarketCapitalization(quote, fundamentals);
344
+ const marketCap: MetricFigure = capitalization
345
+ ? { text: `${formatCompact(capitalization.value)} ${capitalization.currency}`, value: capitalization.value, unit: capitalization.currency }
346
+ : { text: "—" };
347
+ const metrics = fundamentalsMetrics(quote, fundamentals, financials.profile, marketCap, computeTickerPriceReturns(financials), fundamentalsCurrency(financials));
348
+ return (view === "valuation"
349
+ ? metrics.filter(({ label }) => VALUATION_METRICS.has(label) || label.startsWith("Dividend Yield"))
350
+ : metrics).filter((metric) => metric.text !== "—");
351
+ }
352
+
285
353
  /** Text for `gloomberb fundamentals` and `gloomberb valuation`: the ticker report's fundamentals without the rest. */
286
354
  export function renderFundamentalsReport(
287
- financials: TickerFinancials & { symbol: string; exchange?: string },
355
+ financials: FundamentalsReportData,
288
356
  view: "fundamentals" | "valuation",
289
357
  ): string {
290
358
  const quote = financials.quote;
291
- const fundamentals = financials.fundamentals;
292
359
  const profile = financials.profile;
293
- const capitalization = selectMarketCapitalization(quote, fundamentals);
294
- const marketCapText = capitalization
295
- ? `${formatCompact(capitalization.value)} ${capitalization.currency}`
296
- : "—";
297
- const metrics = fundamentalsMetrics(quote, fundamentals, profile, marketCapText, computeTickerPriceReturns(financials));
298
360
  const symbol = quote?.symbol ?? financials.symbol;
299
361
  const name = quote?.name && quote.name !== symbol ? ` ${cliStyles.bold(quote.name)}` : "";
300
362
  const lines = [`${cliStyles.accent(symbol)}${name}`];
@@ -305,16 +367,49 @@ export function renderFundamentalsReport(
305
367
  ].filter((part): part is string => !!part);
306
368
  if (profileParts.length > 0) lines.push(cliStyles.muted(profileParts.join(METADATA_SEPARATOR)));
307
369
 
308
- const shown = view === "valuation"
309
- ? metrics.filter(([label]) => VALUATION_METRICS.has(label) || label.startsWith("Dividend Yield"))
310
- : metrics;
311
370
  const before = lines.length;
312
- appendMetricSection(lines, view === "valuation" ? "Valuation" : "Fundamentals", shown);
371
+ appendMetricSection(lines, view === "valuation" ? "Valuation" : "Fundamentals", metricLines(fundamentalsReportMetrics(financials, view)));
313
372
  if (lines.length === before) lines.push("", cliStyles.muted(`No ${view} reported for ${financials.symbol}.`));
314
373
  if (view === "fundamentals") appendTextSection(lines, "Description", profile?.description);
315
374
  return lines.join("\n");
316
375
  }
317
376
 
377
+ /**
378
+ * `fundamentals` and `valuation` for `--csv` and `--ndjson`: the metrics as
379
+ * `Metric,Value` with each unit in its label, and for `fundamentals` the
380
+ * company profile the text prints around them.
381
+ */
382
+ export function fundamentalsReportTables(
383
+ financials: FundamentalsReportData,
384
+ view: "fundamentals" | "valuation",
385
+ freshness: ReportFreshness,
386
+ ): CliReportTables {
387
+ const metrics = fundamentalsReportMetrics(financials, view);
388
+ const tables = [exportEntriesTable(
389
+ view === "valuation" ? "Valuation" : "Fundamentals",
390
+ metrics.map((metric) => ({ label: metric.label, value: metric.value, formatted: metric.text, unit: metric.unit })),
391
+ )];
392
+ if (view === "fundamentals") {
393
+ const quote = financials.quote;
394
+ const profile = financials.profile;
395
+ tables.push(exportEntriesTable("Profile", [
396
+ { label: "Symbol", value: quote?.symbol ?? financials.symbol },
397
+ { label: "Name", value: quote?.name },
398
+ { label: "Exchange", value: financials.exchange ? exchangeLabel(financials.exchange) : undefined },
399
+ { label: "Sector", value: profile?.sector },
400
+ { label: "Industry", value: profile?.industry },
401
+ { label: "Description", value: profile?.description?.trim() },
402
+ ].filter((entry) => typeof entry.value === "string" && entry.value.trim().length > 0)));
403
+ }
404
+ return {
405
+ tables,
406
+ footer: reportFooterLines({
407
+ freshness,
408
+ notes: metrics.length === 0 ? [`No ${view} reported for ${financials.symbol}.`] : [],
409
+ }),
410
+ };
411
+ }
412
+
318
413
  /** "Euronext Paris (EPA)": the venue the report is for, named in full when the app knows it. */
319
414
  function listingVenueLabel(
320
415
  listingExchange: string | undefined,
@@ -407,10 +502,10 @@ export async function buildTickerReport({
407
502
  : `${formatCompact(capitalization.value)} ${capitalization.currency}`
408
503
  : "—";
409
504
  // Shown in the same currency as the market cap, so the two can be compared.
410
- const enterpriseValue = fundamentals?.enterpriseValue;
505
+ const enterpriseValue = reportedEnterpriseValue(fundamentals);
411
506
  const evCurrency = enterpriseValueCurrency(quote, fundamentals);
412
507
  // A minor unit such as GBp stays as reported: the converter would read it as the major currency.
413
- const convertedEnterpriseValue = enterpriseValue != null && Number.isFinite(enterpriseValue) && evCurrency && /^[A-Z]{3}$/.test(evCurrency)
508
+ const convertedEnterpriseValue = enterpriseValue != null && evCurrency && /^[A-Z]{3}$/.test(evCurrency)
414
509
  ? await toBase(enterpriseValue, evCurrency) : Number.NaN;
415
510
  const enterpriseValueText = Number.isFinite(convertedEnterpriseValue)
416
511
  ? `${formatCompact(convertedEnterpriseValue)} ${config.baseCurrency}`
@@ -447,7 +542,7 @@ export async function buildTickerReport({
447
542
  ]);
448
543
  }
449
544
 
450
- appendMetricSection(lines, "Fundamentals", fundamentalsMetrics(quote, fundamentals, profile, marketCapText, priceReturns, enterpriseValueText));
545
+ appendMetricSection(lines, "Fundamentals", metricLines(fundamentalsMetrics(quote, fundamentals, profile, { text: marketCapText }, priceReturns, fundamentalsCurrency(financials), { text: enterpriseValueText })));
451
546
 
452
547
  if (capitalization?.provenance.kind === "fundamentals") {
453
548
  lines.push(cliStyles.muted(`Market cap: ${describeFundamentalMarketCap(capitalization.provenance)}.`));
@@ -10,6 +10,7 @@ import type { ManualChartResolution } from "../time-series/resolution";
10
10
  import type { SnapshotMarketData } from "../market-data/snapshot-provider";
11
11
  import type { InstrumentRef } from "../market-data/request-types";
12
12
  import type { TickerRecord } from "../types/ticker";
13
+ import type { BrokerAccount } from "../types/trading";
13
14
  import type { PaneRuntimeState } from "../core/state/app/state";
14
15
  import type { RemoteUiNodeSnapshot } from "../remote/types";
15
16
  import type { DatedObservation } from "../plugins/builtin/market-valuation/series";
@@ -54,6 +55,8 @@ export interface DesktopPaneShotPayload {
54
55
  /** Label drawn at the right edge of the pane title bar; null draws nothing. */
55
56
  watermark?: string | null;
56
57
  tickers: TickerRecord[];
58
+ /** Broker accounts as last synced, by profile, so a broker portfolio shows its equity and cash. */
59
+ brokerAccounts?: Record<string, BrokerAccount[]>;
57
60
  financials: Array<[string, TickerFinancials]>;
58
61
  instrumentFinancials?: SnapshotMarketData["instrumentFinancials"];
59
62
  historyVariants?: SnapshotMarketData["historyVariants"];
package/src/cli/help.ts CHANGED
@@ -10,6 +10,12 @@ import {
10
10
  wrapText,
11
11
  } from "../utils/cli-output";
12
12
 
13
+ /** `--section` of a command whose report has several tables. */
14
+ export const TABLE_SECTION_OPTION = {
15
+ flags: "--section <title|n>",
16
+ description: "With --csv or --ndjson, write only that table, by its title or number",
17
+ };
18
+
13
19
  /** Headings of `gloomberb help`, in display order. */
14
20
  export const CLI_COMMAND_GROUPS = {
15
21
  research: "Research",
@@ -29,7 +35,9 @@ const MAX_HELP_WIDTH = 100;
29
35
  const PIPED_HELP_WIDTH = 80;
30
36
 
31
37
  const GLOBAL_OPTIONS: Array<[string, string]> = [
32
- ["--json, --csv, --ndjson", "Print machine-readable output instead of text"],
38
+ ["--json", "Print the full result as JSON"],
39
+ ["--csv", "Print rows as CSV under the columns the text shows; fn reports and fundamentals put each table under # section: and end with # source lines"],
40
+ ["--ndjson", "Print one JSON object per table row"],
33
41
  ["--limit <n>", "Show at most n rows"],
34
42
  ["--refresh", "Fetch fresh data instead of reading the cache"],
35
43
  ["--dry-run", "Preview config, cache, notes, alerts, plugin on/off, and remote changes without saving"],
@@ -178,14 +186,10 @@ function editDistance(left: string, right: string): number {
178
186
  return previous[right.length]!;
179
187
  }
180
188
 
181
- /** The closest command token to a mistyped one, or null when nothing is plausibly meant. */
182
- export function suggestCliCommand(token: string, candidates: Iterable<string>): string | null {
183
- const needle = token.trim().toLowerCase();
184
- if (!needle || needle.startsWith("-")) return null;
189
+ function closestName(needle: string, candidates: Iterable<string>): string | null {
185
190
  const allowed = needle.length <= 4 ? 1 : 2;
186
191
  let best: { name: string; score: number } | null = null;
187
192
  for (const candidate of candidates) {
188
- if (candidate.startsWith("-")) continue;
189
193
  // A clear prefix ("tick" for "ticker") beats an edit of the same size.
190
194
  const score = needle.length >= 3 && candidate.startsWith(needle)
191
195
  ? 0.5
@@ -195,3 +199,19 @@ export function suggestCliCommand(token: string, candidates: Iterable<string>):
195
199
  }
196
200
  return best?.name ?? null;
197
201
  }
202
+
203
+ /** The closest command token to a mistyped one, or null when nothing is plausibly meant. */
204
+ export function suggestCliCommand(token: string, candidates: Iterable<string>): string | null {
205
+ const needle = token.trim().toLowerCase();
206
+ if (!needle || needle.startsWith("-")) return null;
207
+ return closestName(needle, [...candidates].filter((candidate) => !candidate.startsWith("-")));
208
+ }
209
+
210
+ /** The closest of a command's options to a mistyped one (`--rnage` for `--range`), or null. */
211
+ export function suggestCliOption(flag: string, options: Iterable<string>): string | null {
212
+ const needle = flag.trim().toLowerCase().replace(/^-+/, "");
213
+ if (!needle) return null;
214
+ const byName = new Map([...options].map((option) => [option.replace(/^-+/, ""), option] as const));
215
+ const name = closestName(needle, byName.keys());
216
+ return name == null ? null : byName.get(name)!;
217
+ }