gloomberb 0.18.0 → 0.18.1

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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/market-movers.ts +7 -0
  3. package/src/api-client/types.ts +9 -0
  4. package/src/cli/commands/fred-series.ts +15 -5
  5. package/src/cli/commands/market.ts +9 -5
  6. package/src/cli/commands/overview.ts +31 -6
  7. package/src/cli/commands/ticker.ts +5 -2
  8. package/src/cli/pane-functions/function-help.ts +4 -4
  9. package/src/cli/symbol-list.ts +51 -16
  10. package/src/components/chart/composite/composite-chart.tsx +11 -5
  11. package/src/components/chart/composite/panel-surface.tsx +6 -8
  12. package/src/components/chart/composite/price-axis-labels.tsx +2 -1
  13. package/src/components/chart/composite/types.ts +4 -0
  14. package/src/components/command-bar/workflow/collection-actions.ts +4 -0
  15. package/src/components/onboarding/wizard-positions.ts +8 -0
  16. package/src/docs/snapshot.json +52 -17
  17. package/src/plugins/builtin/analytics/index.tsx +1 -1
  18. package/src/plugins/builtin/analytics/risk-client.ts +44 -11
  19. package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
  20. package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
  21. package/src/plugins/builtin/analytics/risk-model.ts +79 -16
  22. package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
  23. package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
  24. package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
  25. package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
  26. package/src/plugins/builtin/custom-view/loader.ts +3 -2
  27. package/src/plugins/builtin/custom-view/pane.tsx +5 -4
  28. package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
  29. package/src/plugins/builtin/econ/calendar-model.ts +3 -2
  30. package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
  31. package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
  32. package/src/plugins/builtin/executives/data.ts +24 -8
  33. package/src/plugins/builtin/executives/index.tsx +2 -18
  34. package/src/plugins/builtin/executives/pane.tsx +34 -10
  35. package/src/plugins/builtin/market-movers/client.ts +2 -2
  36. package/src/plugins/builtin/market-movers/index.tsx +0 -3
  37. package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
  38. package/src/plugins/builtin/market-movers/session.ts +25 -10
  39. package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
  40. package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
  41. package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
  42. package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
  43. package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
  44. package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
  45. package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
  46. package/src/plugins/builtin/notes/cli.ts +33 -8
  47. package/src/plugins/builtin/notes/export.ts +49 -3
  48. package/src/plugins/builtin/notes/files.ts +14 -3
  49. package/src/plugins/builtin/notes/index.tsx +3 -3
  50. package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
  51. package/src/plugins/builtin/notes/store.ts +13 -3
  52. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
  53. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
  54. package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
  55. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
  56. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  57. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
  58. package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
  59. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
  60. package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
  61. package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
  62. package/src/plugins/builtin/thirteenf/api.ts +4 -1
  63. package/src/plugins/builtin/thirteenf/data.ts +5 -1
  64. package/src/plugins/builtin/thirteenf/headless.ts +3 -1
  65. package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
  66. package/src/portfolio/cost-fx.ts +84 -0
  67. package/src/portfolio/mutations.ts +16 -2
  68. package/src/portfolio/position-metrics.ts +45 -4
  69. package/src/portfolio/position-quantity.ts +43 -0
  70. package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
  71. package/src/sync/core-contributors.ts +1 -0
  72. package/src/team/collections.ts +2 -0
  73. package/src/tickers/metadata.ts +10 -0
  74. package/src/types/ticker.ts +9 -0
  75. package/src/utils/currency-units.ts +9 -0
@@ -35,6 +35,13 @@ export interface NotesFilesIO {
35
35
  }
36
36
 
37
37
  const QUICK_NOTES_INDEX_FILE = "__quick-notes-index__.json";
38
+
39
+ export interface NotesListOptions {
40
+ /** A notes folder that cannot be listed rejects instead of listing nothing. */
41
+ strict?: boolean;
42
+ /** Hears each note left out because it could not be read, for a caller that says so. */
43
+ skipped?: (key: string, error: unknown) => void;
44
+ }
38
45
  const LOCAL_KEY_PREFIX = "gloomberb:notes:";
39
46
  const LOCAL_TIMESTAMP_KEY = "gloomberb:notes:__updated-at__";
40
47
 
@@ -237,8 +244,8 @@ export class NotesFiles implements NotesStore {
237
244
  }
238
245
 
239
246
  /** Every stored note, ticker notes and quick notes alike, newest first. */
240
- async list(): Promise<NoteFileEntry[]> {
241
- const { entries } = await this.readEach(await this.io.listKeys());
247
+ async list(options: NotesListOptions = {}): Promise<NoteFileEntry[]> {
248
+ const { entries } = await this.readEach(await this.io.listKeys({ strict: options.strict }), options.skipped);
242
249
  return entries.sort((left, right) => right.updatedAt - left.updatedAt);
243
250
  }
244
251
 
@@ -277,7 +284,10 @@ export class NotesFiles implements NotesStore {
277
284
  }
278
285
 
279
286
  /** An unreadable note is left out and counted rather than synced as empty. */
280
- private async readEach(keys: Array<[string, number]>): Promise<{ entries: NoteFileEntry[]; unreadable: number }> {
287
+ private async readEach(
288
+ keys: Array<[string, number]>,
289
+ skipped?: NotesListOptions["skipped"],
290
+ ): Promise<{ entries: NoteFileEntry[]; unreadable: number }> {
281
291
  const entries: NoteFileEntry[] = [];
282
292
  let unreadable = 0;
283
293
  for (const [key, updatedAt] of keys) {
@@ -286,6 +296,7 @@ export class NotesFiles implements NotesStore {
286
296
  } catch (error) {
287
297
  unreadable += 1;
288
298
  logFailure("notes.files", "Read a note while listing notes failed")(error);
299
+ skipped?.(key, error);
289
300
  }
290
301
  }
291
302
  return { entries, unreadable };
@@ -2,7 +2,7 @@ import { apiClient } from "../../../api-client";
2
2
  import type { GloomPlugin } from "../../../types/plugin";
3
3
  import { teamStore } from "../../../team/store";
4
4
  import { notesCliCommand } from "./cli";
5
- import { exportNotesToDirectory, exportSourceLabel } from "./export";
5
+ import { exportNotesToDirectory, exportNotice, exportSourceLabel } from "./export";
6
6
  import { NotesFiles } from "./files";
7
7
  import { migrateLocalNotes, notesMigratedAt } from "./migration";
8
8
  import { createQuickNotesPane } from "./quick-notes-pane";
@@ -114,8 +114,8 @@ export const notesPlugin: GloomPlugin = {
114
114
  ...teams.map((team) => ({ label: exportSourceLabel(registry.forOwner({ kind: "team", teamId: team.id }), team.name), store: registry.forOwner({ kind: "team", teamId: team.id }) })),
115
115
  ];
116
116
  try {
117
- const result = await exportNotesToDirectory(dir, sources);
118
- ctx.notify({ body: `Exported ${result.files} ${result.files === 1 ? "note" : "notes"} to ${result.dir}`, type: "success" });
117
+ // What was skipped is said with the count, as `notes export` warns.
118
+ ctx.notify(exportNotice(await exportNotesToDirectory(dir, sources)));
119
119
  } catch (error) {
120
120
  ctx.notify({ body: error instanceof Error ? error.message : "Could not export notes.", type: "error" });
121
121
  }
@@ -59,6 +59,9 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
59
59
  const lastSavedTextRef = useRef<Map<string, string>>(new Map());
60
60
  const loadedTabIdRef = useRef<string | null>(null);
61
61
  const loadedRef = useRef(false);
62
+ // Owners whose index exists but could not be read: nothing is saved over it.
63
+ const unreadableIndexRef = useRef(new Set<string>());
64
+ const [indexError, setIndexError] = useState<{ message: string; owners: NoteOwner[] } | null>(null);
62
65
  const activeTab = tabs.find((tab) => tab.id === activeTabId);
63
66
 
64
67
  const saveQuickNotesIndex = useCallback((entries: OwnedQuickNote[]) => {
@@ -73,7 +76,7 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
73
76
  for (const owner of owners) {
74
77
  const bucket = byOwner.get(noteOwnerKey(owner));
75
78
  const store = storeFor(owner);
76
- if (store.readOnly) continue;
79
+ if (store.readOnly || unreadableIndexRef.current.has(noteOwnerKey(owner))) continue;
77
80
  store.saveQuickNotesIndex(bucket?.entries ?? []).catch((error) => {
78
81
  notesLog.error("Failed to save notes index", { error: error instanceof Error ? error.message : String(error) });
79
82
  notify({ body: error instanceof Error ? error.message : "Failed to save notes index.", type: "error" });
@@ -143,17 +146,24 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
143
146
  useEffect(() => {
144
147
  // Mine first, then each team's notes; reloads when the team list changes.
145
148
  let cancelled = false;
149
+ const unreadable = new Map<string, { owner: NoteOwner; message: string }>();
146
150
  void Promise.all(owners.map(async (owner) => {
147
151
  try {
148
- return (await storeFor(owner).loadQuickNotesIndex()).map((entry) => ({ ...entry, owner }));
152
+ return (await storeFor(owner).loadQuickNotesIndex({ strict: true })).map((entry) => ({ ...entry, owner }));
149
153
  } catch (error) {
154
+ unreadable.set(noteOwnerKey(owner), { owner, message: error instanceof Error ? error.message : String(error) });
150
155
  return logFailure("notes.quick-notes", "Load a quick notes index failed", [] as OwnedQuickNote[])(error);
151
156
  }
152
157
  })).then((lists) => {
153
158
  if (cancelled) return;
159
+ unreadableIndexRef.current = new Set(unreadable.keys());
160
+ const failed = [...unreadable.values()];
161
+ setIndexError(failed.length > 0 ? { message: failed[0]!.message, owners: failed.map((entry) => entry.owner) } : null);
154
162
  const entries = lists.flat();
155
163
  if (entries.length === 0) {
156
- if (loadedRef.current) return;
164
+ // Only a first run, with no index yet, gets a first tab; an index
165
+ // that failed to load must not have a fresh one saved over it.
166
+ if (loadedRef.current || unreadable.size > 0) return;
157
167
  const id = generateNoteId();
158
168
  const initial: OwnedQuickNote[] = [{ id, title: "New", owner: { kind: "user" } }];
159
169
  lastSavedTextRef.current.set(id, "");
@@ -224,6 +234,10 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
224
234
  notify({ body: `${ownerLabel(owner, teams)} notes are read-only right now.`, type: "info" });
225
235
  return;
226
236
  }
237
+ if (unreadableIndexRef.current.has(noteOwnerKey(owner))) {
238
+ notify({ body: `${ownerLabel(owner, teams)} quick notes could not be read, so a new one is not added yet.`, type: "info" });
239
+ return;
240
+ }
227
241
  const id = generateNoteId();
228
242
  const entry: OwnedQuickNote = { id, title: "New", owner };
229
243
  lastSavedTextRef.current.set(id, "");
@@ -243,6 +257,14 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
243
257
  const owner = tabOwner(id, tabsRef.current);
244
258
  setTabs((prev) => {
245
259
  const next = prev.filter((t) => t.id !== id);
260
+ // With my index unreadable, a fresh tab of mine could not be saved in it.
261
+ if (next.length === 0 && unreadableIndexRef.current.has(noteOwnerKey({ kind: "user" }))) {
262
+ saveQuickNotesIndex(next);
263
+ setActiveTabId(null);
264
+ setNoteText("");
265
+ prevTabRef.current = null;
266
+ return next;
267
+ }
246
268
  if (next.length === 0) {
247
269
  const newId = generateNoteId();
248
270
  const fresh: OwnedQuickNote[] = [{ id: newId, title: "New", owner: { kind: "user" } }];
@@ -332,6 +354,13 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
332
354
  if (activeTabId) void requestRemoveTab(activeTabId);
333
355
  }, [activeTabId, requestRemoveTab]);
334
356
 
357
+ // With no tab to show, an index that failed to load is the pane's state;
358
+ // beside other owners' tabs it is a footer warning.
359
+ const indexUnreadable = tabs.length === 0 && !!indexError;
360
+ const readError = loadError ?? (indexUnreadable ? indexError!.message : null);
361
+ const footerWarning = readError
362
+ ?? (indexError ? `${indexError.owners.map((owner) => ownerLabel(owner, teams)).join(", ")} quick notes could not be read` : null);
363
+
335
364
  // n, t and d are footer hints, which bind them without modifiers, so the
336
365
  // shell's Ctrl+W or Cmd+N never adds or deletes a note.
337
366
  useShortcut((event) => {
@@ -351,7 +380,7 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
351
380
  }
352
381
 
353
382
  // A note that failed to load stays read-only, as it does for a click.
354
- if (isPlainKey(event, "enter", "return") && !editing && !loadError) {
383
+ if (isPlainKey(event, "enter", "return") && !editing && !readError) {
355
384
  event.preventDefault();
356
385
  setEditing(true);
357
386
  return;
@@ -375,11 +404,11 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
375
404
 
376
405
  // Deleting the only note when it is empty just swaps in another empty one,
377
406
  // and a note that failed to load reads as empty, so it would go unconfirmed.
378
- const canDelete = !!activeTabId && !loadError && (tabs.length > 1 || noteText.trim().length > 0);
407
+ const canDelete = !!activeTabId && !readError && (tabs.length > 1 || noteText.trim().length > 0);
379
408
 
380
409
  usePaneFooter("quick-notes", () => ({
381
- info: loadError
382
- ? [{ id: "load-error", parts: [{ text: loadError, tone: "warning" as const }] }]
410
+ info: footerWarning
411
+ ? [{ id: "load-error", parts: [{ text: footerWarning, tone: "warning" as const }] }]
383
412
  : [
384
413
  { id: "edited", parts: [{ text: editing || renaming ? "editing" : formatLastEdited(activeTab?.updatedAt), tone: "muted" as const }] },
385
414
  ],
@@ -391,7 +420,7 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
391
420
  { id: "title", key: "t", label: "itle", title: "Rename Note", onPress: startRename, disabled: !activeTabId },
392
421
  { id: "delete", key: "d", label: "elete", title: "Delete Note", onPress: deleteActiveNote, disabled: !canDelete },
393
422
  ],
394
- }), [activeTab, activeTabId, addTab, canDelete, deleteActiveNote, editing, loadError, renaming, startRename]);
423
+ }), [activeTab, activeTabId, addTab, canDelete, deleteActiveNote, editing, footerWarning, renaming, startRename]);
395
424
 
396
425
  const noteTabs = tabs.map((tab) => ({
397
426
  label: tab.owner.kind === "team" ? `${ownerLabel(tab.owner, teams)} ${tab.title}` : tab.title,
@@ -437,13 +466,19 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
437
466
  />
438
467
  </Box>
439
468
  )}
440
- <Box flexGrow={1} minHeight={0} paddingX={1} onMouseDown={() => { if (!editing && !renaming && !loadError) setEditing(true); }}>
469
+ <Box flexGrow={1} minHeight={0} paddingX={1} onMouseDown={() => { if (!editing && !renaming && !readError) setEditing(true); }}>
441
470
  {loadError ? (
442
471
  <EmptyState
443
472
  title="This note could not be read."
444
473
  message={loadError}
445
474
  hint="Editing is disabled so the saved note is not overwritten. Fix the file, then reopen the pane."
446
475
  />
476
+ ) : indexUnreadable ? (
477
+ <EmptyState
478
+ title="Quick notes could not be read."
479
+ message={indexError!.message}
480
+ hint="Nothing is saved over them until they load. Reopen the pane to try again."
481
+ />
447
482
  ) : editing && !renaming ? (
448
483
  <MarkdownEditor
449
484
  textareaKey="editing"
@@ -458,7 +493,7 @@ export function createQuickNotesPane(registry: NotesStoreRegistry) {
458
493
  text={noteText}
459
494
  width={width}
460
495
  placeholder="Write notes..."
461
- onActivate={() => { if (!renaming && !loadError) setEditing(true); }}
496
+ onActivate={() => { if (!renaming && !readError) setEditing(true); }}
462
497
  />
463
498
  )}
464
499
  </Box>
@@ -7,7 +7,7 @@ import {
7
7
  type NoteKind,
8
8
  } from "../../../api-client";
9
9
  import type { PluginPersistence } from "../../../types/plugin";
10
- import type { NoteFileEntry, QuickNoteEntry } from "./files";
10
+ import type { NoteFileEntry, NotesListOptions, QuickNoteEntry } from "./files";
11
11
 
12
12
  export { NoteConflictError };
13
13
 
@@ -27,8 +27,18 @@ export interface NotesStore {
27
27
  load(key: string): Promise<string>;
28
28
  save(key: string, text: string, options?: { title?: string | null }): Promise<void>;
29
29
  delete(key: string): Promise<void>;
30
- list(): Promise<NoteFileEntry[]>;
31
- loadQuickNotesIndex(): Promise<QuickNoteEntry[]>;
30
+ /**
31
+ * Every note, newest first. A note that cannot be read is left out and
32
+ * handed to `skipped`; with `strict`, notes that cannot be listed at all
33
+ * reject instead of coming back as none.
34
+ */
35
+ list(options?: NotesListOptions): Promise<NoteFileEntry[]>;
36
+ /**
37
+ * The quick notes' ids and titles. With `strict`, an index that exists but
38
+ * cannot be read rejects instead of reading as empty, for a caller that
39
+ * would otherwise save a replacement over it.
40
+ */
41
+ loadQuickNotesIndex(options?: { strict?: boolean }): Promise<QuickNoteEntry[]>;
32
42
  saveQuickNotesIndex(entries: QuickNoteEntry[]): Promise<void>;
33
43
  quickNoteKey(id: string): string;
34
44
  /** Called after a server-side change so the next load sees it. */
@@ -37,7 +37,7 @@ export const FILE_OPTION = {
37
37
  export const SYMBOL_LIST_HELP = {
38
38
  title: "Several symbols",
39
39
  lines: [
40
- "add and remove take any number of symbols split by spaces, commas or newlines, a --file, or piped input (or -). An entry of a list with commas or newlines that has spaces in it is a company's name when search finds one, read as its US listing first with a line under it saying which, and otherwise the symbols it holds. A CSV is read from its Symbol, Ticker or Code column, else its first. Quote a list name with spaces.",
40
+ "add and remove take any number of symbols split by spaces, commas or newlines, a --file, or piped input (or -). An entry of a list with commas or newlines that has spaces in it is a company's name when search finds one, read as its US listing first with a line under it saying which, and otherwise the symbols it holds. A CSV is read from its Symbol, Ticker or Code column. Without that header every cell is a symbol, unless names, prices or weights sit beside them: then the first column is read and the cells left out are named. Quote a list name with spaces.",
41
41
  "Each symbol gets a line, then a summary. Exit status 0 when at least one symbol was added (or removed) or already was, 1 when none resolved or the usage is wrong.",
42
42
  "A bare US-style symbol is not added when the listing it matched stopped trading, is abroad (DFS as DFS:LSE) or was taken by another company in the last two years (BRKL), or when nothing lists under it now and its company trades as another. Those are listed under Needs a choice, with the add for what their company trades as now and the listing that matched, which naming keeps; one symbol alone fails with the same. A symbol another company took longer ago says so under its line.",
43
43
  "add --dry-run looks every symbol up and prints the same lines without saving.",
@@ -115,14 +115,15 @@ interface CollectionRef {
115
115
  /**
116
116
  * One line per symbol and the summary under them; `--json` and `--csv` give
117
117
  * the rows, with the summary in the JSON metadata and on standard error beside
118
- * CSV. A separator dropped from a symbol of the list is named as a warning. The
119
- * command exits non-zero when no symbol ended up as asked.
118
+ * CSV. A separator dropped from a symbol of the list, and the cells a table
119
+ * read by one column left out, are named as warnings. The command exits
120
+ * non-zero when no symbol ended up as asked.
120
121
  */
121
122
  export function printSymbolRows(
122
123
  ctx: CliCommandContext,
123
124
  rows: SymbolRow[],
124
- { verb, collection, repeated, strays, retry, dryRun = false }: {
125
- verb: Verb; collection: CollectionRef; repeated: number; strays: readonly string[];
125
+ { verb, collection, repeated, notes, retry, dryRun = false }: {
126
+ verb: Verb; collection: CollectionRef; repeated: number; notes: readonly string[];
126
127
  /** The add of some listings, for the line that adds what the symbols needing a choice trade as now. */
127
128
  retry?: (keys: string) => string;
128
129
  dryRun?: boolean;
@@ -137,7 +138,7 @@ export function printSymbolRows(
137
138
  ctx.printResult({
138
139
  data: rows,
139
140
  metadata: { [collection.kind]: collection.name, ...counts, repeated, summary, ...dryRun ? { dryRun } : {} },
140
- ...strays.length > 0 ? { warnings: [...strays] } : {},
141
+ ...notes.length > 0 ? { warnings: [...notes] } : {},
141
142
  }, {
142
143
  text: () => [
143
144
  ...rows.filter((row) => row.status !== "needs_choice").map((row) => rowLines(row, verb)),
@@ -215,7 +216,7 @@ export async function requireSymbolList(
215
216
  file: string | undefined,
216
217
  usage: string,
217
218
  readEntries?: (entries: string[]) => Promise<string[]>,
218
- ): Promise<{ symbols: string[]; repeated: number; strays: string[]; single: string | null }> {
219
+ ): Promise<{ symbols: string[]; repeated: number; notes: string[]; single: string | null }> {
219
220
  try {
220
221
  let typed: string[] = [];
221
222
  const list = await readSymbolList({ args, file, readEntries: async (entries) => {
@@ -47,7 +47,14 @@ import { importSource, notionalPositions, rankThirteenFLines, type ThirteenFImpo
47
47
  import { importedFromText } from "../imported-from";
48
48
  import { savedListingCoinNote } from "../../../../cli/commands/crypto-hints";
49
49
  import { findNamedCollection } from "../../../../portfolio/collection-name";
50
- import { parseTypedNumber, readCurrencyAmount } from "../../../../utils/decimal-comma";
50
+ import { wholeUnitQuantityProblem } from "../../../../portfolio/position-quantity";
51
+ import { CostFxError, loadCostFxRate, type CostFxHistoryClient } from "../../../../portfolio/cost-fx";
52
+ import { lotCostFxRate, sameCurrency } from "../../../../portfolio/position-metrics";
53
+ import { formatFxRate } from "../../../../cli/fx-pair";
54
+ import { createBaseConverter } from "../../../../cli/base-converter";
55
+ import { currencyUnitLabel, heldCurrencyUnit } from "../../../../utils/currency-units";
56
+ import { parseTypedNumber, readCurrencyAmount, readTypedNumber } from "../../../../utils/decimal-comma";
57
+ import { DATE_OPTION_EXAMPLES, dateOptionError, readCalendarDate } from "../../../../utils/calendar-date";
51
58
  import {
52
59
  failPortfolioCommand,
53
60
  isCashArgument,
@@ -66,6 +73,7 @@ import { readNameEntries } from "../../../../cli/name-entries";
66
73
  import { DRIP_DEFAULT_YEARS, DRIP_MAX_YEARS, parseDripYears, parseWithholding } from "../income";
67
74
 
68
75
  const ADD_USAGE = "Usage: gloomberb portfolio add <portfolio> <symbols...> [--file <path>]";
76
+ const POSITION_USAGE = "Usage: gloomberb portfolio position set <portfolio> <ticker> <shares|contracts> <avg-cost> [currency] [--date <purchase date> | --cost-fx <rate>]";
69
77
  const REMOVE_USAGE = "Usage: gloomberb portfolio remove <portfolio> <symbols...> [--file <path>]";
70
78
  const CASH_USAGE = "Usage: gloomberb portfolio cash set <portfolio> <amount> [currency] | portfolio cash clear <portfolio>";
71
79
  const TARGET_USAGE = "Usage: gloomberb portfolio target set <portfolio> <symbol|CASH> <weight%> | target clear <portfolio> [symbol] | target show <portfolio>";
@@ -207,7 +215,7 @@ async function addToPortfolioCommand(args: string[], file: string | undefined, e
207
215
  retry,
208
216
  add: (ticker) => addTickerToPortfolio(ticker, portfolio.id),
209
217
  });
210
- printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated, strays: list.strays, retry, dryRun });
218
+ printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated, notes: list.notes, retry, dryRun });
211
219
  });
212
220
  }
213
221
 
@@ -274,7 +282,7 @@ async function removeFromPortfolioCommand(args: string[], file: string | undefin
274
282
  },
275
283
  });
276
284
  if (nextConfig !== context.config) await saveConfig(nextConfig);
277
- printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated, strays: list.strays });
285
+ printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "portfolio", name: portfolio.name }, repeated: list.repeated, notes: list.notes });
278
286
  });
279
287
  }
280
288
 
@@ -286,6 +294,8 @@ async function setPositionCommand(
286
294
  rawCurrency: string | undefined,
287
295
  exchange: string | undefined,
288
296
  ctx: CliCommandContext,
297
+ /** `--date` (an ISO day) and `--cost-fx`, read already; the client reads the date's FX closes. */
298
+ cost: { date?: string; costFx?: number; history?: CostFxHistoryClient } = {},
289
299
  futuresTerms?: (symbol: string) => Promise<FuturesContractTermsPayload | null>,
290
300
  ) {
291
301
  await withMarketData(ctx, async ({ config, store, dataProvider }) => {
@@ -298,31 +308,56 @@ async function setPositionCommand(
298
308
  const avgCost = parseAmountArg(avgCostValue, "Average cost");
299
309
  // The notes say which listing a bare symbol went to, and that a bare coin ticker is not the coin, as `portfolio add` does.
300
310
  const { ticker, notes } = await resolveCliTicker(symbol, store, dataProvider, exchange);
311
+ // `1.250` on a listing held in whole lots asks which was meant, as `1,250` did above.
312
+ const lots = wholeUnitQuantityProblem(sharesValue, ticker, { listing: savedListingName(ticker).key });
313
+ if (lots) throw new PortfolioCliError(lots);
301
314
  // A future is held in contracts of the size its terms give, as `quote` states it, and a cost typed as
302
315
  // corn is quoted counts cents.
303
316
  const future = isFuturesTicker(ticker);
304
317
  const size = await loadFuturesEntrySize(ticker, futuresTerms);
305
318
  const currency = resolveManualPositionCurrency(rawCurrency, ticker, portfolio, config.baseCurrency, size?.currency);
319
+ // The first position of an empty manual portfolio sets its currency; the cost converts into the currency
320
+ // `portfolio show` values the portfolio in once it is set, which is the base currency when that is not USD.
321
+ const firstPosition = !hasOpenPortfolioPositions(portfolio.id, await store.loadAllTickers());
322
+ const adopted = firstPosition ? adoptFirstPositionCurrency(config, portfolio.id, currency) : null;
323
+ const valued = adopted ?? config;
324
+ const portfolioCurrency = resolvePortfolioTotalsCurrency(
325
+ valued.portfolios.find((entry) => entry.id === portfolio.id) ?? portfolio, valued.baseCurrency);
326
+ const toPortfolio = createBaseConverter(dataProvider, heldCurrencyUnit(portfolioCurrency).currency);
327
+ const costFx = await positionCostFx({
328
+ ...cost, currency, portfolioCurrency, listing: savedListingName(ticker).key,
329
+ // Today's rate, in the units the lot is held in: what a typed rate is checked against.
330
+ todaysRate: async () => (await toPortfolio(1, heldCurrencyUnit(currency).currency))
331
+ / heldCurrencyUnit(currency).divisor * heldCurrencyUnit(portfolioCurrency).divisor,
332
+ });
306
333
  const result = setManualPortfolioPosition(ticker, portfolio.id, {
307
334
  shares,
308
335
  avgCost,
309
336
  currency,
310
337
  ...(size ? { multiplier: size.multiplier } : {}),
338
+ ...(cost.date ? { dateAcquired: cost.date } : {}),
339
+ ...(costFx ? { costFx: costFx.costFx } : {}),
311
340
  });
312
- const multiplier = result.ticker.metadata.positions.find((position) => position.portfolio === portfolio.id)?.multiplier;
341
+ const lot = result.ticker.metadata.positions.find((position) => position.portfolio === portfolio.id);
342
+ const multiplier = lot?.multiplier;
313
343
  if (future && !multiplier) {
314
344
  notes.push(`No contract size is known for ${savedListingName(result.ticker).label}, so its value and P&L are not shown.`);
315
345
  }
316
- const firstPosition = !hasOpenPortfolioPositions(portfolio.id, await store.loadAllTickers());
317
346
  await store.saveTicker(result.ticker);
318
- const adopted = firstPosition ? adoptFirstPositionCurrency(config, portfolio.id, currency) : null;
319
347
  if (adopted) await saveConfig(adopted);
320
348
  const saved = savedListingName(result.ticker);
321
349
  const quantity = formatMarketQuantity(shares, { assetCategory: result.ticker.metadata.assetCategory });
350
+ // A cost in another currency converts at the rate it was bought at when the lot has one, else at today's, and says so.
351
+ const foreign = !sameCurrency(currency, portfolioCurrency);
352
+ const boughtRate = lot ? lotCostFxRate(lot, currency, portfolioCurrency) : null;
353
+ // The purchase date shows where it was given or sets the rate; a lot in the portfolio's currency otherwise prints as it did.
354
+ const purchaseDate = lot?.dateAcquired && (cost.date || foreign) ? lot.dateAcquired : null;
322
355
  ctx.printResult({
323
356
  data: {
324
357
  changed: true, ...listingFields(saved), portfolio: portfolio.name, shares, avgCost, currency,
325
358
  ...(future ? { contractMultiplier: multiplier ?? null, contractSize: size?.size ?? null } : {}),
359
+ ...(purchaseDate ? { dateAcquired: purchaseDate } : {}),
360
+ ...(foreign ? { costFxRate: boughtRate, costFxCurrency: portfolioCurrency, costFxDate: costFx?.date ?? null } : {}),
326
361
  },
327
362
  warnings: notes.length > 0 ? notes : undefined,
328
363
  }, {
@@ -334,7 +369,14 @@ async function setPositionCommand(
334
369
  : ["Shares", quantity],
335
370
  ["Average Cost", formatMarketCostWithCurrency(avgCost, currency, { assetCategory: result.ticker.metadata.assetCategory })],
336
371
  ["Currency", currency],
372
+ ...(purchaseDate ? [["Purchase Date", purchaseDate] as [string, string]] : []),
373
+ ...(boughtRate !== null
374
+ ? [["Cost FX", `${formatFxRate(boughtRate)} ${portfolioCurrency} per ${currency}${costFx?.date ? ` (${costFx.date} close)` : ""}`] as [string, string]]
375
+ : []),
337
376
  ]),
377
+ ...(foreign && boughtRate === null
378
+ ? [cliStyles.muted(`Cost converted at today's rate into ${portfolioCurrency}; add --date or --cost-fx to use the rate when bought.`)]
379
+ : []),
338
380
  ].join("\n"),
339
381
  });
340
382
  } catch (error) {
@@ -343,6 +385,102 @@ async function setPositionCommand(
343
385
  });
344
386
  }
345
387
 
388
+ /**
389
+ * The rate a position's cost was bought at, from `--cost-fx` as typed or from
390
+ * the FX closes of its `--date`, for a position held in another currency
391
+ * than the portfolio's; undefined when neither was given or the position is
392
+ * in the portfolio's currency (a date alone then only dates the lot).
393
+ * `--cost-fx` on a position in the portfolio's own currency, a typed rate
394
+ * that reads as a mistake against today's, and a date with no FX close fail
395
+ * saying what to give instead.
396
+ */
397
+ async function positionCostFx({ date, costFx, history, currency, portfolioCurrency, listing, todaysRate }: {
398
+ date?: string; costFx?: number; history?: CostFxHistoryClient; currency: string; portfolioCurrency: string; listing: string;
399
+ /** Today's rate, one unit of `currency` in `portfolioCurrency`; NaN when it cannot be read. */
400
+ todaysRate: () => Promise<number>;
401
+ }): Promise<{ costFx: { rate: number; currency: string }; date: string | null } | undefined> {
402
+ const foreign = !sameCurrency(currency, portfolioCurrency);
403
+ const insteadHint = `Give the rate it was bought at instead: --cost-fx <${portfolioCurrency} per ${currency}>.`;
404
+ if (costFx != null) {
405
+ if (!foreign) {
406
+ const held = currency === portfolioCurrency ? currency : `${currency}, a unit of ${portfolioCurrency}`;
407
+ throw new PortfolioCliError(`--cost-fx converts a cost in another currency; ${listing} is held in ${held}, the portfolio's currency.`);
408
+ }
409
+ const problem = implausibleCostFx(costFx, await todaysRate().catch(() => Number.NaN), { currency, portfolioCurrency, listing });
410
+ if (problem) throw new PortfolioCliError(problem, "Or give the purchase --date, and its daily FX close is used.");
411
+ return { costFx: { rate: costFx, currency: portfolioCurrency }, date: null };
412
+ }
413
+ if (!date || !foreign) return undefined;
414
+ try {
415
+ const reading = await loadCostFxRate(currency, portfolioCurrency, date, history ?? apiClient);
416
+ return { costFx: { rate: reading.rate, currency: portfolioCurrency }, date: reading.date };
417
+ } catch (error) {
418
+ if (error instanceof CostFxError) {
419
+ throw new PortfolioCliError(`${error.message}, so the ${currency} cost cannot be converted at the rate of ${date}.`, insteadHint);
420
+ }
421
+ // Offline, signed out or a server error: say what was being read, and the way that needs no network.
422
+ const reason = error instanceof Error ? error.message : String(error);
423
+ throw new PortfolioCliError(`Could not read the ${currency} and ${portfolioCurrency} FX closes of ${date}: ${reason}.`, insteadHint);
424
+ }
425
+ }
426
+
427
+ /** How far from today's rate a typed one may be: a currency that lost 99% since purchase still fits. */
428
+ const COST_FX_MAX_RATIO = 100;
429
+
430
+ /**
431
+ * Why a typed `--cost-fx` reads as a mistake against today's rate, else null:
432
+ * a rate per pound for a lot held in pence, the inverse of the pair (0.0319
433
+ * for 31.37 TRY per USD), or more than a hundred times off either way. A
434
+ * rate is only called inverted when today's is far from 1 and the typed one
435
+ * lies on the other side of it, so EUR per USD near parity is never refused.
436
+ * Null when today's rate is not known.
437
+ */
438
+ function implausibleCostFx(
439
+ rate: number,
440
+ today: number,
441
+ { currency, portfolioCurrency, listing }: { currency: string; portfolioCurrency: string; listing: string },
442
+ ): string | null {
443
+ if (!Number.isFinite(today) || today <= 0) return null;
444
+ const pair = `${portfolioCurrency} per ${currency}`;
445
+ const typed = formatFxRate(rate);
446
+ const todays = `today's is ${formatFxRate(today)} ${pair}`;
447
+ const unit = heldCurrencyUnit(currency);
448
+ if (unit.divisor !== 1 && rate / today >= unit.divisor / 5 && rate / today <= unit.divisor * 5) {
449
+ const name = currency.toUpperCase() === "USX" ? "US cents" : currencyUnitLabel(currency).replace(/^\S+ \((.+)\)$/, "$1");
450
+ return `--cost-fx ${typed} looks like ${portfolioCurrency} per ${unit.currency}; ${listing} is held in ${currency} (${name}), `
451
+ + `so the rate is ${pair}, 1/${unit.divisor} of that: ${todays}.`;
452
+ }
453
+ if ((today >= 3 && rate < 1) || (today <= 1 / 3 && rate > 1)) {
454
+ return `--cost-fx ${typed} looks inverted: it is the ${portfolioCurrency} paid for one ${currency}, and ${todays}.`;
455
+ }
456
+ if (rate / today > COST_FX_MAX_RATIO || today / rate > COST_FX_MAX_RATIO) {
457
+ return `--cost-fx ${typed} is more than ${COST_FX_MAX_RATIO} times off today's rate (${formatFxRate(today)} ${pair}); check the rate.`;
458
+ }
459
+ return null;
460
+ }
461
+
462
+ /** `--date`: the day the position was bought, as an ISO day, no later than today (UTC). */
463
+ function parsePurchaseDate(value: string, ctx: CliCommandContext): string {
464
+ const date = readCalendarDate(value);
465
+ if (!date) ctx.fail(dateOptionError(value, "--date", { subject: "the purchase date" }), POSITION_USAGE);
466
+ if (date > new Date().toISOString().slice(0, 10)) ctx.fail(`--date ${value.trim()} is after today; it is the day the position was bought.`, POSITION_USAGE);
467
+ return date;
468
+ }
469
+
470
+ /** `--cost-fx`: one unit of the position's currency in the portfolio's when it was bought, a positive number (32,15 reads as 32.15). */
471
+ function parseCostFx(value: string, ctx: CliCommandContext): number {
472
+ let rate = Number.NaN;
473
+ try {
474
+ rate = readTypedNumber(value, "--cost-fx");
475
+ } catch (error) {
476
+ ctx.fail(error instanceof Error ? error.message : String(error), POSITION_USAGE);
477
+ }
478
+ if (!Number.isFinite(rate) || rate <= 0) {
479
+ ctx.fail(`--cost-fx is the rate the cost was bought at, a positive number such as 32.15 (TRY per USD); got "${value.trim()}".`, POSITION_USAGE);
480
+ }
481
+ return rate;
482
+ }
483
+
346
484
  const CURRENCY_CODE = /^[A-Za-z]{3}$/;
347
485
 
348
486
  /**
@@ -775,6 +913,8 @@ const POSITION_HELP = {
775
913
  lines: [
776
914
  "position set replaces the portfolio's lots of the symbol with one: a negative size is a short, and 0 is refused (portfolio remove drops the symbol).",
777
915
  "A future's size is in contracts, at a price per unit of its contract (GC=F: per troy ounce); the contract size is read from its terms, as quote shows them.",
916
+ "The cost is in the currency named after it, else the listing's. A holding in another currency than the portfolio's (a US stock in a TRY portfolio) can be entered at its cost in the portfolio's currency (position set Lira BRK-B 10 20310 TRY), so P&L is in what was paid.",
917
+ "Entered at its cost in its own currency, its P&L converts the cost at today's rate unless --date gives the purchase date, whose daily FX close is used, or --cost-fx gives the rate paid (portfolio currency per unit of the holding's: 32.15 TRY per USD); then the P&L carries the currency's move since. portfolio show names the holdings whose cost converts at today's rate.",
778
918
  ],
779
919
  };
780
920
 
@@ -792,7 +932,7 @@ const AMOUNTS_HELP = {
792
932
  lines: [
793
933
  "Numbers take either decimal mark: 1.234,5, 1234,5 and 1,234.5 are 1234.5, and 18,50 is 18.5.",
794
934
  "Cash and --notional are amounts of a currency (cash in the portfolio's unless named, --notional in USD), so 500,000 and 1.234 are thousands, a third decimal being below a cent. In a currency with three decimals (KWD, BHD, OMR) they could be either, and the command asks which.",
795
- "Shares, contracts and average cost read a lone dot as a decimal point (123.456) and ask which a lone comma before three digits meant (1,234).",
935
+ "Shares, contracts and average cost read a lone dot as a decimal point (123.456) and ask which a lone comma before three digits meant (1,234). On a listing held in whole lots (shares listed outside the US, a future's contracts) a lone dot before three digits (1.250) asks too.",
796
936
  ],
797
937
  };
798
938
 
@@ -811,7 +951,7 @@ export const portfolioCliCommand: CliCommandDef = {
811
951
  "portfolio delete <name>",
812
952
  "portfolio add <portfolio> <symbols...> [--file <path>]",
813
953
  "portfolio remove <portfolio> <symbols...> [--file <path>]",
814
- "portfolio position set <portfolio> <symbol> <shares|contracts> <avg-cost> [currency]",
954
+ "portfolio position set <portfolio> <symbol> <shares|contracts> <avg-cost> [currency] [--date <purchase date> | --cost-fx <rate>]",
815
955
  "portfolio cash set <portfolio> <amount> [currency]",
816
956
  "portfolio cash clear <portfolio>",
817
957
  "portfolio target set <portfolio> <symbol|CASH> <weight%>",
@@ -831,6 +971,8 @@ export const portfolioCliCommand: CliCommandDef = {
831
971
  { flags: "--name <portfolio>", description: "portfolio import: the portfolio to create or fill (default: the manager's name and 13F)" },
832
972
  { flags: "--notional <amount>", description: "portfolio import: the USD total the shares add up to at the filing's prices (default 100,000)" },
833
973
  { flags: "--replace", description: "portfolio import: replace the positions of an existing portfolio of that name" },
974
+ { flags: "--date <purchase date>", description: `position set: the day it was bought, as ${DATE_OPTION_EXAMPLES}; a holding in another currency converts its cost at that day's FX close` },
975
+ { flags: "--cost-fx <rate>", description: "position set: the rate a holding in another currency was bought at, in the portfolio's currency per unit of the holding's (32.15 TRY per USD)" },
834
976
  { flags: "--explain", description: "portfolio show: follow the report with what its terms mean (Weight, Target, Drift, Trade); gloomberb catalog glossary lists every term" },
835
977
  ],
836
978
  examples: [
@@ -841,6 +983,9 @@ export const portfolioCliCommand: CliCommandDef = {
841
983
  "portfolio add Research --file holdings.csv --dry-run",
842
984
  "portfolio position set Research ASML 10 800 EUR",
843
985
  "portfolio position set Hedge GC=F -10 4216.30",
986
+ "portfolio position set Lira BRK-B 10 20310 TRY",
987
+ "portfolio position set Lira BRK-B 10 410 USD --date 2024-03-01",
988
+ "portfolio position set Lira KO 40 62.50 USD --cost-fx 32.15",
844
989
  "portfolio cash set Research 25000 USD",
845
990
  "portfolio target set Research ASML 12.5%",
846
991
  "portfolio target set Research CASH 10",
@@ -889,6 +1034,16 @@ export const portfolioCliCommand: CliCommandDef = {
889
1034
  ctx.fail(error instanceof Error ? error.message : String(error), dripUsage);
890
1035
  }
891
1036
  const basis = close ? "regular-close" as const : "latest-print" as const;
1037
+ // A position's purchase date and the rate its cost was bought at, read before anything is looked up.
1038
+ const hasDate = args.some((arg) => arg === "--date" || arg.startsWith("--date="));
1039
+ const dateText = hasDate ? takeOption(args, "--date") ?? "" : undefined;
1040
+ const hasCostFx = args.some((arg) => arg === "--cost-fx" || arg.startsWith("--cost-fx="));
1041
+ const costFxText = hasCostFx ? takeOption(args, "--cost-fx") ?? "" : undefined;
1042
+ if ((hasDate || hasCostFx) && action !== "position") {
1043
+ ctx.fail(`${hasDate ? "--date" : "--cost-fx"} is an option of portfolio position set, not portfolio ${action ?? "list"}.`, POSITION_USAGE);
1044
+ }
1045
+ const purchaseDate = dateText != null ? parsePurchaseDate(dateText, ctx) : undefined;
1046
+ const costFx = costFxText != null ? parseCostFx(costFxText, ctx) : undefined;
892
1047
  const hasFile = args.some((arg) => arg === "--file" || arg.startsWith("--file="));
893
1048
  const file = hasFile ? takeOption(args, "--file") ?? "" : undefined;
894
1049
  if (file != null && action !== "add" && action !== "remove") ctx.fail(`--file is an option of portfolio add and remove, not portfolio ${action ?? "list"}.`, ADD_USAGE);
@@ -936,15 +1091,11 @@ export const portfolioCliCommand: CliCommandDef = {
936
1091
 
937
1092
  if (action === "position") {
938
1093
  const subaction = args[1];
939
- if (subaction !== "set") {
940
- ctx.fail("Usage: gloomberb portfolio position set <portfolio> <ticker> <shares|contracts> <avg-cost> [currency]");
941
- }
1094
+ if (subaction !== "set") ctx.fail(POSITION_USAGE);
942
1095
  const [, , ...rest] = args;
943
1096
  const currency = rest[4];
944
- if (rest.length < 4) {
945
- ctx.fail("Usage: gloomberb portfolio position set <portfolio> <ticker> <shares|contracts> <avg-cost> [currency]");
946
- }
947
- await setPositionCommand(rest[0]!, rest[1]!, rest[2]!, rest[3]!, currency, exchange, ctx);
1097
+ if (rest.length < 4) ctx.fail(POSITION_USAGE);
1098
+ await setPositionCommand(rest[0]!, rest[1]!, rest[2]!, rest[3]!, currency, exchange, ctx, { date: purchaseDate, costFx });
948
1099
  return;
949
1100
  }
950
1101