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.
- package/package.json +1 -1
- package/src/api-client/market-movers.ts +7 -0
- package/src/api-client/types.ts +9 -0
- package/src/cli/commands/fred-series.ts +15 -5
- package/src/cli/commands/market.ts +9 -5
- package/src/cli/commands/overview.ts +31 -6
- package/src/cli/commands/ticker.ts +5 -2
- package/src/cli/pane-functions/function-help.ts +4 -4
- package/src/cli/symbol-list.ts +51 -16
- package/src/components/chart/composite/composite-chart.tsx +11 -5
- package/src/components/chart/composite/panel-surface.tsx +6 -8
- package/src/components/chart/composite/price-axis-labels.tsx +2 -1
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/command-bar/workflow/collection-actions.ts +4 -0
- package/src/components/onboarding/wizard-positions.ts +8 -0
- package/src/docs/snapshot.json +52 -17
- package/src/plugins/builtin/analytics/index.tsx +1 -1
- package/src/plugins/builtin/analytics/risk-client.ts +44 -11
- package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
- package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
- package/src/plugins/builtin/analytics/risk-model.ts +79 -16
- package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
- package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
- package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
- package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
- package/src/plugins/builtin/custom-view/loader.ts +3 -2
- package/src/plugins/builtin/custom-view/pane.tsx +5 -4
- package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
- package/src/plugins/builtin/econ/calendar-model.ts +3 -2
- package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
- package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
- package/src/plugins/builtin/executives/data.ts +24 -8
- package/src/plugins/builtin/executives/index.tsx +2 -18
- package/src/plugins/builtin/executives/pane.tsx +34 -10
- package/src/plugins/builtin/market-movers/client.ts +2 -2
- package/src/plugins/builtin/market-movers/index.tsx +0 -3
- package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
- package/src/plugins/builtin/market-movers/session.ts +25 -10
- package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
- package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
- package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
- package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
- package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
- package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
- package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
- package/src/plugins/builtin/notes/cli.ts +33 -8
- package/src/plugins/builtin/notes/export.ts +49 -3
- package/src/plugins/builtin/notes/files.ts +14 -3
- package/src/plugins/builtin/notes/index.tsx +3 -3
- package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
- package/src/plugins/builtin/notes/store.ts +13 -3
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
- package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
- package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
- package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
- package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
- package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
- package/src/plugins/builtin/thirteenf/api.ts +4 -1
- package/src/plugins/builtin/thirteenf/data.ts +5 -1
- package/src/plugins/builtin/thirteenf/headless.ts +3 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
- package/src/portfolio/cost-fx.ts +84 -0
- package/src/portfolio/mutations.ts +16 -2
- package/src/portfolio/position-metrics.ts +45 -4
- package/src/portfolio/position-quantity.ts +43 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
- package/src/sync/core-contributors.ts +1 -0
- package/src/team/collections.ts +2 -0
- package/src/tickers/metadata.ts +10 -0
- package/src/types/ticker.ts +9 -0
- 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(
|
|
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
|
-
|
|
118
|
-
ctx.notify(
|
|
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
|
-
|
|
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 && !
|
|
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 && !
|
|
407
|
+
const canDelete = !!activeTabId && !readError && (tabs.length > 1 || noteText.trim().length > 0);
|
|
379
408
|
|
|
380
409
|
usePaneFooter("quick-notes", () => ({
|
|
381
|
-
info:
|
|
382
|
-
? [{ id: "load-error", parts: [{ text:
|
|
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,
|
|
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 && !
|
|
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 && !
|
|
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
|
-
|
|
31
|
-
|
|
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,
|
|
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
|
|
119
|
-
*
|
|
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,
|
|
125
|
-
verb: Verb; collection: CollectionRef; repeated: number;
|
|
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
|
-
...
|
|
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;
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|