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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -1,4 +1,5 @@
1
1
  import type { AppResourceStorePort } from "../core/app-service-ports";
2
+ import type { CachedResourceRecord, ResourceCacheKey } from "../data/resource-store";
2
3
  import type { BrokerAdapter } from "../types/broker";
3
4
  import type { BrokerInstanceConfig } from "../types/config";
4
5
  import type { CachePolicy } from "../types/persistence";
@@ -21,6 +22,50 @@ function brokerAccountNamespace(instance: BrokerInstanceConfig): string {
21
22
  return `plugin:${instance.brokerType}`;
22
23
  }
23
24
 
25
+ /** A saved account snapshot, which a renderer without the cache database has to keep elsewhere. */
26
+ export function isBrokerAccountSnapshotKey(key: Pick<ResourceCacheKey, "namespace" | "kind">): boolean {
27
+ return key.kind === BROKER_ACCOUNT_SNAPSHOT_KIND && key.namespace.startsWith("plugin:");
28
+ }
29
+
30
+ /**
31
+ * Saves an account snapshot written by a process without the cache database
32
+ * (the desktop view), keeping its own times. Anything else is refused.
33
+ */
34
+ export function saveBrokerAccountRecord(
35
+ resources: Pick<AppResourceStorePort, "set">,
36
+ record: CachedResourceRecord | null | undefined,
37
+ ): boolean {
38
+ if (!record || typeof record.namespace !== "string" || typeof record.entityKey !== "string"
39
+ || !isBrokerAccountSnapshotKey(record)
40
+ || ![record.fetchedAt, record.staleAt, record.expiresAt].every(Number.isFinite)) return false;
41
+ resources.set(record, record.value, {
42
+ schemaVersion: record.schemaVersion,
43
+ provenance: record.provenance,
44
+ fetchedAt: record.fetchedAt,
45
+ cachePolicy: { staleMs: record.staleAt - record.fetchedAt, expireMs: record.expiresAt - record.fetchedAt },
46
+ });
47
+ return true;
48
+ }
49
+
50
+ export function deleteBrokerAccountRecord(resources: Pick<AppResourceStorePort, "delete">, key: ResourceCacheKey): void {
51
+ if (isBrokerAccountSnapshotKey(key)) resources.delete(key);
52
+ }
53
+
54
+ /** Every unexpired account snapshot saved for these profiles, as stored. */
55
+ export function listPersistedBrokerAccountRecords(
56
+ resources: Pick<AppResourceStorePort, "list">,
57
+ brokerInstances: readonly BrokerInstanceConfig[],
58
+ ): CachedResourceRecord[] {
59
+ return brokerInstances.flatMap((instance) => resources.list<PersistedBrokerAccountSnapshot>({
60
+ namespace: brokerAccountNamespace(instance),
61
+ kind: BROKER_ACCOUNT_SNAPSHOT_KIND,
62
+ entityKey: instance.id,
63
+ }, {
64
+ schemaVersion: BROKER_ACCOUNT_SNAPSHOT_SCHEMA_VERSION,
65
+ touch: false,
66
+ }));
67
+ }
68
+
24
69
  export function getBrokerAccountCacheSourceKey(
25
70
  instance: BrokerInstanceConfig,
26
71
  broker?: BrokerAdapter | null,
@@ -0,0 +1,74 @@
1
+ import type { CliCommandDef } from "../types/plugin";
2
+ import { fail } from "./errors";
3
+ import { suggestCliOption } from "./help";
4
+
5
+ /** A built-in command, which may say more about an option it does not take than the list of those it does. */
6
+ export interface BuiltinCliCommandDef extends CliCommandDef {
7
+ /** A sentence for an unknown option people reach for, such as `--from` on `history`; null for the rest. */
8
+ unknownOptionHint?(flag: string): string | null;
9
+ }
10
+
11
+ interface DeclaredOption {
12
+ flag: string;
13
+ takesValue: boolean;
14
+ }
15
+
16
+ /** `--range`, `--range=5Y`: an option. `-5`, `---`, `--` and plain words are not. */
17
+ const OPTION_TOKEN = /^--[a-z]/i;
18
+
19
+ /**
20
+ * The options a command's help declares, from each spelling such as
21
+ * `--range <range>` (takes a value) or `--all` (a switch). Null when it takes
22
+ * any option: `--<option> <value>` on `fn` and `shot`.
23
+ */
24
+ function declaredOptions(command: CliCommandDef): Map<string, DeclaredOption> | null {
25
+ const declared = new Map<string, DeclaredOption>();
26
+ for (const option of command.help?.options ?? []) {
27
+ for (const spelling of option.flags.split(",")) {
28
+ const trimmed = spelling.trim();
29
+ if (trimmed.startsWith("--<")) return null;
30
+ const match = /^(--?[a-z][\w-]*)(?:[ =](.+))?$/i.exec(trimmed);
31
+ if (match) declared.set(match[1]!, { flag: match[1]!, takesValue: match[2] != null });
32
+ }
33
+ }
34
+ return declared;
35
+ }
36
+
37
+ function failUnknownOption(command: BuiltinCliCommandDef, flag: string, declared: Map<string, DeclaredOption>): never {
38
+ const flags = [...declared.keys()];
39
+ const suggestion = suggestCliOption(flag, flags);
40
+ const help = `See gloomberb help ${command.name}.`;
41
+ fail(`Unknown option ${flag} for ${command.name}.`, [
42
+ command.unknownOptionHint?.(flag) ?? "",
43
+ suggestion ? `Did you mean ${suggestion}?` : "",
44
+ flags.length > 0 ? `Options: ${flags.join(", ")}. ${help}` : `${command.name} takes no options. ${help}`,
45
+ ].filter(Boolean).join("\n"));
46
+ }
47
+
48
+ /**
49
+ * Fails on the first `--option` a built-in command does not declare, which it
50
+ * would otherwise ignore and answer as if it had not been given: `history
51
+ * --from 1995-01-01` printing the default year. Global flags are already out
52
+ * of `args`; a declared option's value is skipped whatever it looks like, and
53
+ * nothing from `literalStart` on (after a bare `--`) is read as an option.
54
+ */
55
+ export function checkCliCommandOptions(
56
+ command: BuiltinCliCommandDef,
57
+ args: readonly string[],
58
+ literalStart = args.length,
59
+ ): void {
60
+ const declared = declaredOptions(command);
61
+ if (!declared) return;
62
+ for (let index = 0; index < Math.min(literalStart, args.length); index += 1) {
63
+ const arg = args[index]!;
64
+ if (!OPTION_TOKEN.test(arg)) continue;
65
+ const equals = arg.indexOf("=");
66
+ const flag = equals >= 0 ? arg.slice(0, equals) : arg;
67
+ const option = declared.get(flag);
68
+ if (!option) failUnknownOption(command, flag, declared);
69
+ if (!option.takesValue && equals >= 0) {
70
+ fail(`${flag} takes no value.`, `Write it alone, as ${flag}. See gloomberb help ${command.name}.`);
71
+ }
72
+ if (option.takesValue && equals < 0) index += 1;
73
+ }
74
+ }
@@ -46,6 +46,35 @@ export function requireArg(value: string | undefined, usage: string, ctx: CliCom
46
46
  return value;
47
47
  }
48
48
 
49
+ /**
50
+ * Fails on arguments past the `max` a command takes rather than dropping them:
51
+ * `fx NGN ZAR` printing NGN alone reads as an answer for both. `usage` is the
52
+ * command's usage line without `gloomberb`, `takes` what it takes ("one
53
+ * currency", "no arguments").
54
+ */
55
+ export function rejectExtraArgs(
56
+ args: readonly string[],
57
+ max: number,
58
+ { usage, takes, advice }: { usage: string; takes: string; advice?: string },
59
+ ctx: Pick<CliCommandContext, "fail">,
60
+ ): void {
61
+ if (args.length <= max) return;
62
+ const command = usage.split(" ")[0];
63
+ ctx.fail(`${command} takes ${takes}; got ${args.join(" ")}.`, [advice, `Usage: gloomberb ${usage}`].filter(Boolean).join("\n"));
64
+ }
65
+
66
+ /** The one argument a command takes, such as a symbol: missing fails with the usage line, extras are not dropped. */
67
+ export function requireOneArg(
68
+ args: readonly string[],
69
+ usage: string,
70
+ noun: string,
71
+ ctx: CliCommandContext,
72
+ ): string {
73
+ const value = requireArg(args[0], `Usage: gloomberb ${usage}`, ctx);
74
+ rejectExtraArgs(args, 1, { usage, takes: `one ${noun}`, advice: `Run it once per ${noun}.` }, ctx);
75
+ return value;
76
+ }
77
+
49
78
  export function isoDate(value: unknown): string {
50
79
  if (value instanceof Date) return value.toISOString();
51
80
  if (typeof value === "number") return new Date(value).toISOString();
@@ -13,8 +13,9 @@ import { currencyMinorDigits, formatMarketPrice, formatMarketPriceWithCurrency,
13
13
  import { getActiveQuoteDisplay, marketStateLabel } from "../../market-data/market/status";
14
14
  import { formatCompact, formatDistributionAmount, formatPercent } from "../../utils/format";
15
15
  import { withCliServices, withMarketData } from "../context";
16
- import { isoDate, parsePositiveInt, requireArg, takeFlag, takeOption } from "./command-utils";
17
- import { CLI_COMMAND_GROUPS } from "../help";
16
+ import { isoDate, parsePositiveInt, rejectExtraArgs, requireOneArg, takeFlag, takeOption } from "./command-utils";
17
+ import type { BuiltinCliCommandDef } from "../command-options";
18
+ import { CLI_COMMAND_GROUPS, TABLE_SECTION_OPTION } from "../help";
18
19
  import {
19
20
  formatChangePercentCell,
20
21
  formatCountCell,
@@ -33,10 +34,27 @@ import {
33
34
  } from "../../plugins/builtin/research/analyst-model";
34
35
  import { optionQuoteSide } from "../../plugins/builtin/options/market-reference";
35
36
  import { getPublishedUsEquityCalendarYears, getPublishedUsEquitySession } from "../../market-data/published-us-sessions";
36
- import { renderFundamentalsReport } from "./ticker";
37
- import { historyPriceDecimals, historyRows } from "../history-rows";
37
+ import { fundamentalsReportTables, renderFundamentalsReport } from "./ticker";
38
+ import type { CliResultColumn } from "../result";
39
+ import {
40
+ exportRowsTable,
41
+ reportFooterLines,
42
+ selectReportTables,
43
+ type CliReportTables,
44
+ } from "../report-tables";
45
+ import {
46
+ historyFacts,
47
+ historyFlagNote,
48
+ historyAllRangeYears,
49
+ historyIntervalsByRange,
50
+ historyNotes,
51
+ historyPriceDecimals,
52
+ historyRows,
53
+ historyUnit,
54
+ } from "../history-rows";
38
55
  import { CRYPTO_BOARD_HINT, quoteNotes } from "./crypto-hints";
39
56
  import { formatUtcTime } from "../../utils/utc-time";
57
+ import { isFiniteNumber } from "../../utils/guards";
40
58
  import { exportedFundamentals } from "../../utils/price-earnings";
41
59
  import { fundamentalsFreshness, quotesFreshness, rowsFreshness } from "../freshness";
42
60
  import {
@@ -65,11 +83,14 @@ import { nonUsSecListingVenue } from "../../utils/sec";
65
83
  import type { MarketContext } from "../types";
66
84
  import {
67
85
  holderListFacts,
86
+ holderShareBasisMarker,
87
+ holderShareBasisNote,
68
88
  holderValueBasis,
69
89
  moneyColumnHeader,
70
90
  nonUsHolderCaveat,
71
91
  sharedReportDate,
72
92
  } from "../../plugins/builtin/holders/report-header";
93
+ import { formatHolderOwnershipPercent, holderValueCurrency } from "../../plugins/builtin/holders/format";
73
94
  import { fetchBeneficialOwners } from "../../plugins/builtin/holders/beneficial-client";
74
95
  import { filingFormMatches, SEC_FILING_FETCH_LIMIT } from "../../plugins/builtin/sec/forms";
75
96
  import {
@@ -250,6 +271,7 @@ function holderRows(data: HolderData, ownerTypes?: Set<string>) {
250
271
  value: holder.value ?? null,
251
272
  percentHeld: holder.percentHeld ?? null,
252
273
  changeShares: holder.changeShares ?? null,
274
+ shareBasis: holder.shareBasis ?? null,
253
275
  }));
254
276
  }
255
277
 
@@ -421,11 +443,16 @@ function failHistory(error: unknown, symbol: string, ctx: Parameters<CliCommandD
421
443
  return ctx.fail(errorMessage(error) ?? `No history available for ${symbol}`, CRYPTO_BOARD_HINT);
422
444
  }
423
445
 
446
+ const HISTORY_USAGE = "history <symbol> [--range <range>]";
447
+ const HISTORY_ALL_RANGE = `ALL gives the full history the source has, up to ${historyAllRangeYears()} years`;
448
+ /** Options people reach for to ask for dates, which history answers with a range. */
449
+ const DATE_WINDOW_OPTIONS = new Set(["--from", "--to", "--start", "--end", "--since", "--until", "--date", "--start-date", "--end-date"]);
450
+
424
451
  async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
425
452
  const args = [...rawArgs];
426
453
  const range = parseRange(takeOption(args, "--range"), ctx);
427
454
  const requestedExchange = takeOption(args, "--exchange");
428
- const raw = requireArg(args[0], "Usage: gloomberb history <symbol> [--range <range>]", ctx);
455
+ const raw = requireOneArg(args, HISTORY_USAGE, "symbol", ctx);
429
456
  await withMarketData(ctx, async (market) => {
430
457
  const listing = await requireCliListing(raw, requestedExchange, market, ctx);
431
458
  const { symbol, exchange } = listing.request;
@@ -439,7 +466,11 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
439
466
  loadListingQuote(market.dataProvider, listing),
440
467
  ]);
441
468
  const identity = listingIdentity(listing, quote);
442
- const data = historyRows(points, resolution);
469
+ // Without a quote currency, the listing metadata research reads (as `ticker` does) may still state it.
470
+ const listed = quote?.currency ? quote : await market.dataProvider.getQuoteMetadata?.(symbol, exchange).catch(() => null);
471
+ const unit = historyUnit(listing.symbol, listed ?? quote);
472
+ const data = historyRows(points, resolution, unit.currency);
473
+ const interval = data[0]?.interval ?? null;
443
474
  const decimals = historyPriceDecimals(data, listing.saved?.metadata.assetCategory);
444
475
  const price = (value: unknown) => typeof value === "number" ? value.toFixed(decimals) : "";
445
476
  // Intraday bars print in UTC, as the charts and time and sales label them, not the host zone.
@@ -450,17 +481,47 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
450
481
  observedKey: "date",
451
482
  oldest: null,
452
483
  });
453
- ctx.printResult({ data, metadata: { ...listingMetadata(identity), range, resolution }, freshness }, {
454
- heading: listingHeading(identity),
484
+ const notes = [...historyNotes(range, interval, data[0]?.date), historyFlagNote(data) ?? []].flat();
485
+ const priceColumns: CliResultColumn[] = [
486
+ intraday
487
+ ? { key: "date", header: "Time", format: (value) => typeof value === "string" ? formatUtcTime(value) : "" }
488
+ : { key: "date", header: "Date" },
489
+ { key: "open", header: "Open", align: "right", format: price },
490
+ { key: "high", header: "High", align: "right", format: price },
491
+ { key: "low", header: "Low", align: "right", format: price },
492
+ { key: "close", header: "Close", align: "right", format: price },
493
+ { key: "volume", header: "Volume", align: "right", format: formatCountCell },
494
+ ];
495
+ // Text shows a flag only on a bar that has one; the column is dropped when none does.
496
+ const flagColumn: CliResultColumn = {
497
+ key: "flag", header: "Flag", format: (value) => typeof value === "string" ? cliStyles.warning(value) : "",
498
+ };
499
+ ctx.printResult({
500
+ data,
501
+ metadata: {
502
+ ...listingMetadata(identity),
503
+ range,
504
+ resolution,
505
+ requestedRange: range,
506
+ currency: unit.currency,
507
+ unit: unit.unit,
508
+ interval,
509
+ firstDate: data[0]?.date ?? null,
510
+ lastDate: data.at(-1)?.date ?? null,
511
+ bars: data.length,
512
+ asOf: freshness?.asOf ?? null,
513
+ },
514
+ ...(notes.length > 0 ? { warnings: notes } : {}),
515
+ freshness,
516
+ }, {
517
+ heading: `${listingHeading(identity)}\n${cliStyles.muted(historyFacts(unit, data).join(" · "))}`,
518
+ textColumns: [...priceColumns, flagColumn],
519
+ // Every exported row says its currency and bar size, so it survives head and concatenation.
455
520
  columns: [
456
- intraday
457
- ? { key: "date", header: "Time", format: (value) => typeof value === "string" ? formatUtcTime(value) : "" }
458
- : { key: "date", header: "Date" },
459
- { key: "open", header: "Open", align: "right", format: price },
460
- { key: "high", header: "High", align: "right", format: price },
461
- { key: "low", header: "Low", align: "right", format: price },
462
- { key: "close", header: "Close", align: "right", format: price },
463
- { key: "volume", header: "Volume", align: "right", format: formatCountCell },
521
+ ...priceColumns,
522
+ { key: "currency", header: "Currency" },
523
+ { key: "interval", header: "Interval" },
524
+ flagColumn,
464
525
  ],
465
526
  });
466
527
  });
@@ -468,11 +529,33 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
468
529
 
469
530
  type FinancialsView = "statements" | "fundamentals" | "valuation";
470
531
 
532
+ const STATEMENT_COLUMNS: CliResultColumn<ReturnType<typeof financialStatementRows>[number]>[] = [
533
+ { key: "date", header: "Date" },
534
+ { key: "revenue", header: "Revenue", align: "right", format: (value) => value == null ? "" : formatCompact(Number(value)) },
535
+ { key: "grossProfit", header: "Gross", align: "right", format: (value) => value == null ? "" : formatCompact(Number(value)) },
536
+ { key: "operatingIncome", header: "Op Inc", align: "right", format: (value) => value == null ? "" : formatCompact(Number(value)) },
537
+ { key: "netIncome", header: "Net Inc", align: "right", format: (value) => value == null ? "" : formatCompact(Number(value)) },
538
+ { key: "eps", header: "EPS", align: "right", format: (value) => value == null ? "" : formatPerShareNumber(Number(value)) },
539
+ { key: "currency", header: "Cur" },
540
+ ];
541
+
471
542
  async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1], view: FinancialsView) {
472
543
  const args = [...rawArgs];
473
544
  const exchangeOption = takeOption(args, "--exchange");
545
+ const sectionFlag = args.some((arg) => arg === "--section" || arg.startsWith("--section="));
546
+ const section = takeOption(args, "--section");
474
547
  const commandName = view === "statements" ? "financials" : view;
475
- const raw = requireArg(args[0], `Usage: gloomberb ${commandName} <symbol>`, ctx);
548
+ const tabular = ctx.cliOptions.format === "csv" || ctx.cliOptions.format === "ndjson";
549
+ if (sectionFlag && !tabular) ctx.fail("--section picks one table of --csv or --ndjson output.");
550
+ if (sectionFlag && !section?.trim()) ctx.fail("--section needs a section title or number.");
551
+ const raw = requireOneArg(args, `${commandName} <symbol>`, "symbol", ctx);
552
+ const selectTables = (tables: CliReportTables) => {
553
+ try {
554
+ return selectReportTables(tables, section);
555
+ } catch (error) {
556
+ return ctx.fail(error instanceof Error ? error.message : String(error));
557
+ }
558
+ };
476
559
  await withMarketData(ctx, async (market) => {
477
560
  const listing = await requireCliListing(raw, exchangeOption, market, ctx);
478
561
  const financials = await loadForListing(listing, market, ctx, () => market.dataProvider.getTickerFinancials(
@@ -486,13 +569,28 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
486
569
  ...financials,
487
570
  };
488
571
  if (view !== "statements") {
572
+ const freshness = fundamentalsFreshness(financials);
489
573
  ctx.printResult({
490
574
  // JSON reads a multiple over a loss as null with its reason, never as a number.
491
575
  data: { ...data, fundamentals: exportedFundamentals(data.fundamentals) },
492
- freshness: fundamentalsFreshness(financials),
493
- }, { text: () => renderFundamentalsReport(data, view) });
576
+ freshness,
577
+ }, {
578
+ text: () => renderFundamentalsReport(data, view),
579
+ ...(tabular ? { tables: selectTables(fundamentalsReportTables(data, view, freshness)) } : {}),
580
+ });
494
581
  return;
495
582
  }
583
+ const rows = financialStatementRows(data);
584
+ const freshness = rowsFreshness(rows, {
585
+ ...REPORTED_DATA, basis: "financial statements", observedKey: "date", oldest: null,
586
+ });
587
+ // NDJSON keeps the raw statement rows it always wrote; CSV gets the table and its closing lines.
588
+ const tables = tabular
589
+ ? selectTables({
590
+ tables: [exportRowsTable("Annual statements", STATEMENT_COLUMNS, rows)],
591
+ footer: reportFooterLines({ freshness }),
592
+ })
593
+ : undefined;
496
594
  ctx.printResult({
497
595
  data,
498
596
  metadata: {
@@ -503,21 +601,12 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
503
601
  fundamentals: exportedFundamentals(financials.fundamentals),
504
602
  profile: financials.profile,
505
603
  },
506
- freshness: rowsFreshness(financialStatementRows(data), {
507
- ...REPORTED_DATA, basis: "financial statements", observedKey: "date", oldest: null,
508
- }),
604
+ freshness,
509
605
  }, {
510
606
  heading: listingHeading(identity),
511
- rows: financialStatementRows,
512
- columns: [
513
- { key: "date", header: "Date" },
514
- { key: "revenue", header: "Revenue", align: "right", value: (row) => row.revenue == null ? "" : formatCompact(Number(row.revenue)) },
515
- { key: "grossProfit", header: "Gross", align: "right", value: (row) => row.grossProfit == null ? "" : formatCompact(Number(row.grossProfit)) },
516
- { key: "operatingIncome", header: "Op Inc", align: "right", value: (row) => row.operatingIncome == null ? "" : formatCompact(Number(row.operatingIncome)) },
517
- { key: "netIncome", header: "Net Inc", align: "right", value: (row) => row.netIncome == null ? "" : formatCompact(Number(row.netIncome)) },
518
- { key: "eps", header: "EPS", align: "right", format: (value) => value == null ? "" : formatPerShareNumber(Number(value)) },
519
- { key: "currency", header: "Cur" },
520
- ],
607
+ rows: () => rows,
608
+ columns: STATEMENT_COLUMNS,
609
+ ...(tables && ctx.cliOptions.format === "csv" ? { tables } : {}),
521
610
  });
522
611
  });
523
612
  }
@@ -527,6 +616,7 @@ async function runNews(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
527
616
  const feed = parseNewsFeed(takeOption(args, "--feed"));
528
617
  const exchangeOption = takeOption(args, "--exchange");
529
618
  if (exchangeOption && !args[0]) ctx.fail("--exchange needs a symbol: gloomberb news <symbol> --exchange <code>");
619
+ rejectExtraArgs(args, 1, { usage: "news [symbol] [--feed <feed>]", takes: "one symbol at most", advice: "Run it once per symbol." }, ctx);
530
620
  await withMarketData(ctx, async (market) => {
531
621
  const listing = args[0] ? await requireCliListing(args[0], exchangeOption, market, ctx) : null;
532
622
  const limit = ctx.cliOptions.limit ?? 20;
@@ -587,7 +677,7 @@ async function runFilings(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
587
677
  const count = parsePositiveInt(takeOption(args, "--count"), ctx.cliOptions.limit ?? 15, "Count", ctx);
588
678
  const exchangeOption = takeOption(args, "--exchange");
589
679
  const form = takeOption(args, "--form")?.trim() || null;
590
- const raw = requireArg(args[0], "Usage: gloomberb filings <symbol>", ctx);
680
+ const raw = requireOneArg(args, "filings <symbol> [--count <n>] [--form <form>]", "symbol", ctx);
591
681
  await withMarketData(ctx, async (market) => {
592
682
  const listing = await requireCliListing(raw, exchangeOption, market, ctx);
593
683
  const { symbol, exchange } = listing.request;
@@ -702,7 +792,7 @@ async function runHolders(
702
792
  const exchangeOption = takeOption(args, "--exchange");
703
793
  const formOption = commandName === "holders" ? takeOption(args, "--form") : undefined;
704
794
  const history = commandName === "holders" && takeFlag(args, "--history");
705
- const raw = requireArg(args[0], `Usage: gloomberb ${commandName} <symbol>`, ctx);
795
+ const raw = requireOneArg(args, `${commandName} <symbol>`, "symbol", ctx);
706
796
  const form = formOption == null ? "13f" : parseHolderForm(formOption);
707
797
  if (!form) ctx.fail(`Unknown form "${formOption}".`, `Use one of ${HOLDER_FORMS.join(", ")}.`);
708
798
  if (form === "13f" && history) ctx.fail("--history lists 13D/13G reports.", "Add --form 13d, 13g or all.");
@@ -724,16 +814,23 @@ async function runHolders(
724
814
  const institutional = !ownerTypes?.has("insider");
725
815
  const total = institutional ? data.summary?.institutionsCount ?? null : null;
726
816
  const reportDate = sharedReportDate(rows);
727
- const valueBasis = rows.length > 0 ? holderValueBasis(reportDate) : null;
817
+ // A London line's values can be dollars from the 13F filings; the listing's currency is not their unit.
818
+ const valueCurrency = holderValueCurrency(data);
819
+ const valueBasis = rows.length > 0 ? holderValueBasis(reportDate, data.valueBasis, valueCurrency) : null;
728
820
  const positionsBasis = rows.length > 0 ? nonUsHolderCaveat(identity.exchange || data.exchange, data.currency) : null;
821
+ const shareBasisNote = holderShareBasisNote(data, rows);
822
+ const markedRows = rows.some((row) => holderShareBasisMarker(row));
729
823
  // The heading names the listing; its unit, date and how much of the list follows on the same line.
730
- const facts = holderListFacts({ currency: data.currency, asOf: data.asOf, shown, reported: rows.length, total });
824
+ const facts = holderListFacts({
825
+ currency: valueCurrency, listingCurrency: data.currency, asOf: data.asOf, shown, reported: rows.length, total,
826
+ });
731
827
  ctx.printResult({
732
828
  data,
733
829
  metadata: {
734
830
  ...listingMetadata(identity),
735
831
  summary: data.summary,
736
832
  currency: data.currency ?? null,
833
+ valueCurrency: valueCurrency ?? null,
737
834
  asOf: data.asOf ?? null,
738
835
  shown,
739
836
  reported: rows.length,
@@ -750,16 +847,23 @@ async function runHolders(
750
847
  { key: "name", header: "Holder" },
751
848
  { key: "reportDate", header: "Date" },
752
849
  { key: "shares", header: "Shares", align: "right", format: formatCountCell },
850
+ // Marks the rows of a home line held as receipts; absent when no row is.
851
+ ...(markedRows ? [{ key: "shareBasis", header: "Basis", value: (row: Record<string, unknown>) => holderShareBasisMarker(row) }] : []),
753
852
  {
754
853
  key: "value",
755
- header: moneyColumnHeader("Value", data.currency),
854
+ header: moneyColumnHeader("Value", valueCurrency),
756
855
  align: "right",
757
856
  value: (row) => row.value == null ? "" : formatCompact(Number(row.value)),
758
857
  },
759
- { key: "percentHeld", header: "% Held", align: "right", format: formatFractionPercentCell },
858
+ {
859
+ key: "percentHeld",
860
+ header: "% Held",
861
+ align: "right",
862
+ format: (value) => isFiniteNumber(value) ? formatHolderOwnershipPercent(value) : "",
863
+ },
760
864
  ],
761
865
  summary: (holderData: HolderData) => [
762
- [valueBasis ? `Value = ${valueBasis}.` : "", positionsBasis ?? ""].filter(Boolean).join(" "),
866
+ [valueBasis ? `Value = ${valueBasis}.` : "", positionsBasis ?? "", shareBasisNote ?? ""].filter(Boolean).join(" "),
763
867
  commandName === "insider" && ownerTypes ? insiderSummary(holderData, ownerTypes) : "",
764
868
  ].filter(Boolean).join("\n"),
765
869
  empty: `No holders reported for ${listingTitle(identity)}.`,
@@ -770,7 +874,7 @@ async function runHolders(
770
874
  async function runAnalyst(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
771
875
  const args = [...rawArgs];
772
876
  const exchangeOption = takeOption(args, "--exchange");
773
- const raw = requireArg(args[0], "Usage: gloomberb analyst <symbol>", ctx);
877
+ const raw = requireOneArg(args, "analyst <symbol>", "symbol", ctx);
774
878
  await withMarketData(ctx, async (market) => {
775
879
  const listing = await requireCliListing(raw, exchangeOption, market, ctx);
776
880
  const [data, quote] = await Promise.all([
@@ -807,7 +911,7 @@ async function runAnalyst(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
807
911
  async function runEvents(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
808
912
  const args = [...rawArgs];
809
913
  const exchangeOption = takeOption(args, "--exchange");
810
- const raw = requireArg(args[0], "Usage: gloomberb events <symbol>", ctx);
914
+ const raw = requireOneArg(args, "events <symbol>", "symbol", ctx);
811
915
  await withMarketData(ctx, async (market) => {
812
916
  const listing = await requireCliListing(raw, exchangeOption, market, ctx);
813
917
  const [data, quote] = await Promise.all([
@@ -837,7 +941,7 @@ async function runOptions(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
837
941
  const args = [...rawArgs];
838
942
  const expiration = takeOption(args, "--expiration");
839
943
  const exchangeOption = takeOption(args, "--exchange");
840
- const raw = requireArg(args[0], "Usage: gloomberb options <symbol> [--expiration <unix>]", ctx);
944
+ const raw = requireOneArg(args, "options <symbol> [--expiration <unix>]", "symbol", ctx);
841
945
  await withMarketData(ctx, async (market) => {
842
946
  const listing = await requireCliListing(raw, exchangeOption, market, ctx);
843
947
  const { symbol, exchange } = listing.request;
@@ -884,8 +988,10 @@ async function runOptions(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
884
988
  });
885
989
  }
886
990
 
991
+ const FX_USAGE = "fx <currency>";
992
+
887
993
  async function runFx(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
888
- const currency = requireArg(rawArgs[0]?.trim().toUpperCase(), "Usage: gloomberb fx <currency>", ctx);
994
+ const currency = requireOneArg(rawArgs.map((arg) => arg.trim().toUpperCase()), FX_USAGE, "currency", ctx);
889
995
  await withMarketData(ctx, async (market) => {
890
996
  const baseCurrency = market.config.baseCurrency.trim().toUpperCase();
891
997
  const load = (code: string) => market.dataProvider.getCachedQuery("getExchangeRate", [code])
@@ -948,7 +1054,7 @@ async function runEarnings(rawArgs: string[], ctx: Parameters<CliCommandDef["exe
948
1054
  });
949
1055
  }
950
1056
 
951
- export const marketDataCliCommands: CliCommandDef[] = [
1057
+ export const marketDataCliCommands: BuiltinCliCommandDef[] = [
952
1058
  {
953
1059
  name: "quote",
954
1060
  description: "Show the latest price for one or more symbols",
@@ -976,13 +1082,23 @@ export const marketDataCliCommands: CliCommandDef[] = [
976
1082
  description: "Fetch open, high, low, close, and volume over a range",
977
1083
  help: {
978
1084
  group: CLI_COMMAND_GROUPS.research,
979
- usage: ["history <symbol> [--range <range>]"],
1085
+ usage: [HISTORY_USAGE],
980
1086
  options: [
981
- { flags: "--range <range>", description: `${TIME_RANGES.join(", ")} (default 1Y)` },
1087
+ { flags: "--range <range>", description: `${TIME_RANGES.join(", ")} (default 1Y); ${HISTORY_ALL_RANGE}` },
982
1088
  EXCHANGE_OPTION,
983
1089
  ],
984
- examples: ["history AAPL", "history BHP:ASX --range 5Y", "history AAPL --range 5Y --csv > aapl.csv"],
1090
+ sections: [{
1091
+ title: "Bars",
1092
+ lines: [
1093
+ `Each range comes in one bar size: ${historyIntervalsByRange()}. The line under the heading, and the --json metadata, give the currency, the bar size actually served, the first and last bar and the bar count. A warning says when the bars are coarser, or start later, than the range asked for.`,
1094
+ "A bar whose prices contradict each other (high below the open or close, or low above them) is left blank and flagged, such as high<open, with the count in a warning. Every exported row (--csv, --ndjson, --json) carries currency, interval and flag.",
1095
+ ],
1096
+ }],
1097
+ examples: ["history AAPL", "history BHP:ASX --range 5Y", "history ZAR=X --range ALL", "history AAPL --range 5Y --csv > aapl.csv"],
985
1098
  },
1099
+ unknownOptionHint: (flag) => DATE_WINDOW_OPTIONS.has(flag)
1100
+ ? `history takes a --range instead of dates: ${TIME_RANGES.join(", ")}. ${HISTORY_ALL_RANGE}.`
1101
+ : null,
986
1102
  execute: runHistory,
987
1103
  },
988
1104
  {
@@ -1032,8 +1148,8 @@ export const marketDataCliCommands: CliCommandDef[] = [
1032
1148
  help: {
1033
1149
  group: CLI_COMMAND_GROUPS.companyData,
1034
1150
  usage: ["financials <symbol>"],
1035
- options: [EXCHANGE_OPTION],
1036
- examples: ["financials MSFT", "financials SAN:EPA", "financials MSFT --json"],
1151
+ options: [EXCHANGE_OPTION, TABLE_SECTION_OPTION],
1152
+ examples: ["financials MSFT", "financials SAN:EPA", "financials MSFT --json", "financials MSFT --csv > msft.csv"],
1037
1153
  },
1038
1154
  execute: (args, ctx) => runFinancials(args, ctx, "statements"),
1039
1155
  },
@@ -1043,8 +1159,8 @@ export const marketDataCliCommands: CliCommandDef[] = [
1043
1159
  help: {
1044
1160
  group: CLI_COMMAND_GROUPS.companyData,
1045
1161
  usage: ["fundamentals <symbol>"],
1046
- options: [EXCHANGE_OPTION],
1047
- examples: ["fundamentals NVDA", "fundamentals ASML:AMS"],
1162
+ options: [EXCHANGE_OPTION, TABLE_SECTION_OPTION],
1163
+ examples: ["fundamentals NVDA", "fundamentals ASML:AMS", "fundamentals NVDA --csv --section fundamentals"],
1048
1164
  },
1049
1165
  execute: (args, ctx) => runFinancials(args, ctx, "fundamentals"),
1050
1166
  },
@@ -1054,8 +1170,8 @@ export const marketDataCliCommands: CliCommandDef[] = [
1054
1170
  help: {
1055
1171
  group: CLI_COMMAND_GROUPS.companyData,
1056
1172
  usage: ["valuation <symbol>"],
1057
- options: [EXCHANGE_OPTION],
1058
- examples: ["valuation NVDA", "valuation BP:LSE"],
1173
+ options: [EXCHANGE_OPTION, TABLE_SECTION_OPTION],
1174
+ examples: ["valuation NVDA", "valuation BP:LSE", "valuation NVDA --csv"],
1059
1175
  },
1060
1176
  execute: (args, ctx) => runFinancials(args, ctx, "valuation"),
1061
1177
  },
@@ -1167,7 +1283,7 @@ export const marketDataCliCommands: CliCommandDef[] = [
1167
1283
  description: "Convert a currency into your base currency",
1168
1284
  help: {
1169
1285
  group: CLI_COMMAND_GROUPS.markets,
1170
- usage: ["fx <currency>"],
1286
+ usage: [FX_USAGE],
1171
1287
  examples: ["fx EUR", "fx JPY --json"],
1172
1288
  },
1173
1289
  execute: runFx,