gloomberb 0.3.7 → 0.4.0
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/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +359 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +45 -1
- package/src/plugins/ibkr/gateway-service.ts +141 -7
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +11 -3
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +119 -57
- package/src/plugins/prediction-markets/pane.tsx +74 -153
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useRef, useEffect, useState } from "react";
|
|
2
|
-
import { useKeyboard } from "@opentui/react";
|
|
3
2
|
import type { GloomPlugin, DetailTabProps } from "../../types/plugin";
|
|
4
3
|
import { usePaneTicker } from "../../state/app-context";
|
|
5
4
|
import { colors } from "../../theme/colors";
|
|
@@ -88,15 +87,6 @@ function NewsTab({ width, height, focused }: DetailTabProps) {
|
|
|
88
87
|
setSummaryCache((prev) => new Map(prev).set(selected.url, selectedSummary));
|
|
89
88
|
}, [selected?.url, selectedSummary]);
|
|
90
89
|
|
|
91
|
-
useKeyboard((event) => {
|
|
92
|
-
if (!focused || news.length === 0) return;
|
|
93
|
-
if (event.name === "j" || event.name === "down") {
|
|
94
|
-
setSelectedIdx((index) => Math.min(index + 1, news.length - 1));
|
|
95
|
-
} else if (event.name === "k" || event.name === "up") {
|
|
96
|
-
setSelectedIdx((index) => Math.max(index - 1, 0));
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
90
|
useEffect(() => {
|
|
101
91
|
if (news.length > 0 && selectedIdx >= news.length) {
|
|
102
92
|
setSelectedIdx(Math.max(0, news.length - 1));
|
|
@@ -114,13 +104,13 @@ function NewsTab({ width, height, focused }: DetailTabProps) {
|
|
|
114
104
|
<DetailFeedView
|
|
115
105
|
width={width}
|
|
116
106
|
height={height}
|
|
107
|
+
focused={focused}
|
|
117
108
|
items={items}
|
|
118
109
|
selectedIdx={selectedIdx}
|
|
119
110
|
hoveredIdx={hoveredIdx}
|
|
120
111
|
onSelect={setSelectedIdx}
|
|
121
112
|
onHover={setHoveredIdx}
|
|
122
113
|
listVariant="single-line"
|
|
123
|
-
splitListWidthRatio={0.36}
|
|
124
114
|
/>
|
|
125
115
|
);
|
|
126
116
|
}
|
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { saveConfig } from "../../../../data/config-store";
|
|
2
|
+
import { createBaseConverter } from "../../../../cli/base-converter";
|
|
3
|
+
import {
|
|
4
|
+
countCollectionTickers,
|
|
5
|
+
findWatchlist,
|
|
6
|
+
formatSignedCurrency,
|
|
7
|
+
formatSignedPercentRaw,
|
|
8
|
+
slugifyName,
|
|
9
|
+
} from "../../../../cli/helpers";
|
|
10
|
+
import { resolveTickerForCli } from "../../../../cli/ticker-resolution";
|
|
11
|
+
import {
|
|
12
|
+
cliStyles,
|
|
13
|
+
colorBySign,
|
|
14
|
+
renderSection,
|
|
15
|
+
renderStat,
|
|
16
|
+
renderTable,
|
|
17
|
+
} from "../../../../utils/cli-output";
|
|
18
|
+
import { exchangeShortName } from "../../../../utils/market-status";
|
|
19
|
+
import { formatCompact, formatCurrency } from "../../../../utils/format";
|
|
20
|
+
import type { AppConfig } from "../../../../types/config";
|
|
21
|
+
import type { CliCommandContext, CliCommandDef } from "../../../../types/plugin";
|
|
22
|
+
import type { TickerRecord } from "../../../../types/ticker";
|
|
23
|
+
import {
|
|
24
|
+
addTickerToPortfolio,
|
|
25
|
+
createManualPortfolio,
|
|
26
|
+
deleteManualPortfolio,
|
|
27
|
+
findPortfolio,
|
|
28
|
+
isManualPortfolio,
|
|
29
|
+
removeTickerFromPortfolio,
|
|
30
|
+
resolveManualPositionCurrency,
|
|
31
|
+
setManualPortfolioPosition,
|
|
32
|
+
} from "../mutations";
|
|
33
|
+
|
|
34
|
+
function renderCollectionOverview(config: AppConfig, tickers: TickerRecord[]): string {
|
|
35
|
+
const blocks: string[] = [];
|
|
36
|
+
|
|
37
|
+
blocks.push(renderSection("Portfolios"));
|
|
38
|
+
if (config.portfolios.length === 0) {
|
|
39
|
+
blocks.push(cliStyles.muted("No portfolios configured."));
|
|
40
|
+
} else {
|
|
41
|
+
blocks.push(renderTable(
|
|
42
|
+
[
|
|
43
|
+
{ header: "Portfolio" },
|
|
44
|
+
{ header: "Currency" },
|
|
45
|
+
{ header: "Tickers", align: "right" },
|
|
46
|
+
],
|
|
47
|
+
config.portfolios.map((portfolio) => [
|
|
48
|
+
portfolio.name,
|
|
49
|
+
portfolio.currency,
|
|
50
|
+
String(countCollectionTickers(tickers, "portfolios", portfolio.id)),
|
|
51
|
+
]),
|
|
52
|
+
));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
blocks.push("");
|
|
56
|
+
blocks.push(renderSection("Watchlists"));
|
|
57
|
+
if (config.watchlists.length === 0) {
|
|
58
|
+
blocks.push(cliStyles.muted("No watchlists configured."));
|
|
59
|
+
} else {
|
|
60
|
+
blocks.push(renderTable(
|
|
61
|
+
[
|
|
62
|
+
{ header: "Watchlist" },
|
|
63
|
+
{ header: "Tickers", align: "right" },
|
|
64
|
+
],
|
|
65
|
+
config.watchlists.map((watchlist) => [
|
|
66
|
+
watchlist.name,
|
|
67
|
+
String(countCollectionTickers(tickers, "watchlists", watchlist.id)),
|
|
68
|
+
]),
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return blocks.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function showCollection(name: string, ctx: CliCommandContext) {
|
|
76
|
+
const { config, store, dataProvider, persistence } = await ctx.initMarketData();
|
|
77
|
+
const tickers = (await store.loadAllTickers()).sort((left, right) =>
|
|
78
|
+
left.metadata.ticker.localeCompare(right.metadata.ticker)
|
|
79
|
+
);
|
|
80
|
+
const baseCurrency = config.baseCurrency;
|
|
81
|
+
const toBase = createBaseConverter(dataProvider, baseCurrency);
|
|
82
|
+
|
|
83
|
+
const normalized = name.trim().toLowerCase();
|
|
84
|
+
const matchedPortfolio = config.portfolios.find((portfolio) =>
|
|
85
|
+
portfolio.id.toLowerCase() === normalized || portfolio.name.toLowerCase() === normalized
|
|
86
|
+
);
|
|
87
|
+
const matchedWatchlist = config.watchlists.find((watchlist) =>
|
|
88
|
+
watchlist.id.toLowerCase() === normalized || watchlist.name.toLowerCase() === normalized
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!matchedPortfolio && !matchedWatchlist) {
|
|
92
|
+
ctx.closeAndFail(
|
|
93
|
+
persistence,
|
|
94
|
+
`Collection "${name}" was not found.`,
|
|
95
|
+
`Available: ${[...config.portfolios.map((portfolio) => portfolio.name), ...config.watchlists.map((watchlist) => watchlist.name)].join(", ")}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isPortfolio = !!matchedPortfolio;
|
|
100
|
+
const id = matchedPortfolio?.id ?? matchedWatchlist!.id;
|
|
101
|
+
const displayName = matchedPortfolio?.name ?? matchedWatchlist!.name;
|
|
102
|
+
const currency = matchedPortfolio?.currency ?? baseCurrency;
|
|
103
|
+
const filtered = tickers.filter((ticker) =>
|
|
104
|
+
isPortfolio ? ticker.metadata.portfolios.includes(id) : ticker.metadata.watchlists.includes(id)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (filtered.length === 0) {
|
|
108
|
+
console.log(cliStyles.bold(displayName));
|
|
109
|
+
console.log(cliStyles.muted("No tickers in this collection."));
|
|
110
|
+
persistence.close();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const quotes = new Map<string, Awaited<ReturnType<typeof dataProvider.getQuote>>>();
|
|
115
|
+
await Promise.all(
|
|
116
|
+
filtered.map(async (ticker) => {
|
|
117
|
+
try {
|
|
118
|
+
const quote = await dataProvider.getQuote(ticker.metadata.ticker, ticker.metadata.exchange);
|
|
119
|
+
quotes.set(ticker.metadata.ticker, quote);
|
|
120
|
+
} catch {
|
|
121
|
+
// Ignore partial quote failures so the rest of the table still renders.
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
console.log(cliStyles.bold(displayName + (isPortfolio ? ` (${currency})` : "")));
|
|
127
|
+
console.log(cliStyles.muted(`${filtered.length} ticker${filtered.length === 1 ? "" : "s"}`));
|
|
128
|
+
console.log("");
|
|
129
|
+
|
|
130
|
+
if (isPortfolio) {
|
|
131
|
+
let totalPnl = 0;
|
|
132
|
+
const rows: string[][] = [];
|
|
133
|
+
|
|
134
|
+
for (const ticker of filtered) {
|
|
135
|
+
const quote = quotes.get(ticker.metadata.ticker);
|
|
136
|
+
const positions = ticker.metadata.positions.filter((position) => position.portfolio === id);
|
|
137
|
+
const priceText = quote ? colorBySign(formatCurrency(quote.price, quote.currency), quote.change) : "—";
|
|
138
|
+
const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—";
|
|
139
|
+
|
|
140
|
+
if (positions.length === 0) {
|
|
141
|
+
rows.push([ticker.metadata.ticker, priceText, changeText, "—", "—", "—"]);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const position of positions) {
|
|
146
|
+
const multiplier = position.multiplier ?? 1;
|
|
147
|
+
const quoteCurrency = quote?.currency ?? ticker.metadata.currency ?? baseCurrency;
|
|
148
|
+
const positionCurrency = position.currency ?? quoteCurrency;
|
|
149
|
+
const currentValueBase = quote
|
|
150
|
+
? await toBase(Math.abs(position.shares) * quote.price * multiplier, quoteCurrency)
|
|
151
|
+
: null;
|
|
152
|
+
const costBasisBase = await toBase(position.shares * position.avgCost * multiplier, positionCurrency);
|
|
153
|
+
const pnl = currentValueBase != null ? currentValueBase - costBasisBase : null;
|
|
154
|
+
if (pnl != null) totalPnl += pnl;
|
|
155
|
+
|
|
156
|
+
rows.push([
|
|
157
|
+
ticker.metadata.ticker,
|
|
158
|
+
priceText,
|
|
159
|
+
changeText,
|
|
160
|
+
String(position.shares),
|
|
161
|
+
formatCurrency(position.avgCost, positionCurrency),
|
|
162
|
+
pnl == null ? "—" : colorBySign(formatSignedCurrency(pnl, baseCurrency), pnl),
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(renderTable(
|
|
168
|
+
[
|
|
169
|
+
{ header: "Ticker" },
|
|
170
|
+
{ header: "Last", align: "right" },
|
|
171
|
+
{ header: "Chg", align: "right" },
|
|
172
|
+
{ header: "Shares", align: "right" },
|
|
173
|
+
{ header: "Avg Cost", align: "right" },
|
|
174
|
+
{ header: "P&L", align: "right" },
|
|
175
|
+
],
|
|
176
|
+
rows,
|
|
177
|
+
));
|
|
178
|
+
console.log("");
|
|
179
|
+
console.log(renderStat("Total P&L", colorBySign(formatSignedCurrency(totalPnl, baseCurrency), totalPnl)));
|
|
180
|
+
} else {
|
|
181
|
+
const rows: string[][] = [];
|
|
182
|
+
for (const ticker of filtered) {
|
|
183
|
+
const quote = quotes.get(ticker.metadata.ticker);
|
|
184
|
+
const priceText = quote ? colorBySign(formatCurrency(quote.price, quote.currency), quote.change) : "—";
|
|
185
|
+
const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—";
|
|
186
|
+
const marketCapText = quote?.marketCap != null
|
|
187
|
+
? `${formatCompact(await toBase(quote.marketCap, quote.currency || ticker.metadata.currency || baseCurrency))} ${baseCurrency}`
|
|
188
|
+
: "—";
|
|
189
|
+
|
|
190
|
+
rows.push([
|
|
191
|
+
ticker.metadata.ticker,
|
|
192
|
+
exchangeShortName(quote?.exchangeName, quote?.fullExchangeName) || ticker.metadata.exchange || "—",
|
|
193
|
+
priceText,
|
|
194
|
+
changeText,
|
|
195
|
+
marketCapText,
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(renderTable(
|
|
200
|
+
[
|
|
201
|
+
{ header: "Ticker" },
|
|
202
|
+
{ header: "Exchange" },
|
|
203
|
+
{ header: "Last", align: "right" },
|
|
204
|
+
{ header: "Chg", align: "right" },
|
|
205
|
+
{ header: "Mkt Cap", align: "right" },
|
|
206
|
+
],
|
|
207
|
+
rows,
|
|
208
|
+
));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
persistence.close();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseFiniteNumber(rawValue: string | undefined, label: string): number {
|
|
215
|
+
const value = Number(rawValue);
|
|
216
|
+
if (!Number.isFinite(value)) {
|
|
217
|
+
throw new Error(`${label} must be a valid number.`);
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requireManualPortfolio(config: AppConfig, rawName: string): NonNullable<ReturnType<typeof findPortfolio>> {
|
|
223
|
+
const portfolio = findPortfolio(config, rawName);
|
|
224
|
+
if (!portfolio) {
|
|
225
|
+
throw new Error(`Portfolio "${rawName}" was not found.`);
|
|
226
|
+
}
|
|
227
|
+
if (!isManualPortfolio(portfolio)) {
|
|
228
|
+
throw new Error(`Portfolio "${portfolio.name}" is broker-managed and cannot be modified manually.`);
|
|
229
|
+
}
|
|
230
|
+
return portfolio;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function listCollections(ctx: CliCommandContext) {
|
|
234
|
+
const { config, store, persistence } = await ctx.initConfigData();
|
|
235
|
+
const tickers = await store.loadAllTickers();
|
|
236
|
+
console.log(renderCollectionOverview(config, tickers));
|
|
237
|
+
persistence.close();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function createPortfolioCommand(name: string, ctx: CliCommandContext) {
|
|
241
|
+
const { config, persistence } = await ctx.initConfigData();
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const result = createManualPortfolio(config, name, config.baseCurrency);
|
|
245
|
+
await saveConfig(result.config);
|
|
246
|
+
console.log(cliStyles.success(`Created portfolio "${result.portfolio.name}".`));
|
|
247
|
+
console.log(renderStat("ID", result.portfolio.id));
|
|
248
|
+
} catch (error: any) {
|
|
249
|
+
ctx.closeAndFail(persistence, error?.message || `Failed to create portfolio "${name}".`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
persistence.close();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function deletePortfolioCommand(name: string, ctx: CliCommandContext) {
|
|
256
|
+
const { config, store, persistence } = await ctx.initConfigData();
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const portfolio = requireManualPortfolio(config, name);
|
|
260
|
+
const result = deleteManualPortfolio(config, await store.loadAllTickers(), portfolio.id);
|
|
261
|
+
for (const ticker of result.tickers) {
|
|
262
|
+
await store.saveTicker(ticker);
|
|
263
|
+
}
|
|
264
|
+
await saveConfig(result.config);
|
|
265
|
+
console.log(cliStyles.success(`Deleted portfolio "${result.portfolio.name}".`));
|
|
266
|
+
console.log(renderStat("Cleaned Tickers", String(result.cleanedTickerCount)));
|
|
267
|
+
console.log(renderStat("Removed Positions", String(result.removedPositionCount)));
|
|
268
|
+
} catch (error: any) {
|
|
269
|
+
ctx.closeAndFail(persistence, error?.message || `Failed to delete portfolio "${name}".`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
persistence.close();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function addTickerToPortfolioCommand(portfolioName: string, symbol: string, ctx: CliCommandContext) {
|
|
276
|
+
const { config, store, dataProvider, persistence } = await ctx.initMarketData();
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const portfolio = requireManualPortfolio(config, portfolioName);
|
|
280
|
+
const ticker = await resolveTickerForCli(symbol, store, dataProvider);
|
|
281
|
+
const result = addTickerToPortfolio(ticker, portfolio.id);
|
|
282
|
+
if (!result.changed) {
|
|
283
|
+
console.log(cliStyles.warning(`${ticker.metadata.ticker} is already in "${portfolio.name}".`));
|
|
284
|
+
persistence.close();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
await store.saveTicker(result.ticker);
|
|
288
|
+
console.log(cliStyles.success(`Added ${result.ticker.metadata.ticker} to "${portfolio.name}".`));
|
|
289
|
+
if (result.ticker.metadata.name) {
|
|
290
|
+
console.log(renderStat("Name", result.ticker.metadata.name));
|
|
291
|
+
}
|
|
292
|
+
} catch (error: any) {
|
|
293
|
+
ctx.closeAndFail(persistence, error?.message || `Failed to add ${symbol} to "${portfolioName}".`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
persistence.close();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function removeTickerFromPortfolioCommand(portfolioName: string, symbol: string, ctx: CliCommandContext) {
|
|
300
|
+
const { config, store, persistence } = await ctx.initConfigData();
|
|
301
|
+
const normalized = symbol.trim().toUpperCase();
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const portfolio = requireManualPortfolio(config, portfolioName);
|
|
305
|
+
const ticker = await store.loadTicker(normalized);
|
|
306
|
+
if (!ticker) {
|
|
307
|
+
ctx.closeAndFail(persistence, `Ticker "${normalized}" was not found in your local data.`);
|
|
308
|
+
}
|
|
309
|
+
const result = removeTickerFromPortfolio(ticker, portfolio.id);
|
|
310
|
+
if (!result.changed) {
|
|
311
|
+
ctx.closeAndFail(persistence, `${normalized} is not in "${portfolio.name}".`);
|
|
312
|
+
}
|
|
313
|
+
await store.saveTicker(result.ticker);
|
|
314
|
+
console.log(cliStyles.success(`Removed ${normalized} from "${portfolio.name}".`));
|
|
315
|
+
console.log(renderStat("Removed Positions", String(result.removedPositionCount)));
|
|
316
|
+
} catch (error: any) {
|
|
317
|
+
ctx.closeAndFail(persistence, error?.message || `Failed to remove ${symbol} from "${portfolioName}".`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
persistence.close();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function setPositionCommand(
|
|
324
|
+
portfolioName: string,
|
|
325
|
+
symbol: string,
|
|
326
|
+
sharesValue: string,
|
|
327
|
+
avgCostValue: string,
|
|
328
|
+
rawCurrency: string | undefined,
|
|
329
|
+
ctx: CliCommandContext,
|
|
330
|
+
) {
|
|
331
|
+
const { config, store, dataProvider, persistence } = await ctx.initMarketData();
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const portfolio = requireManualPortfolio(config, portfolioName);
|
|
335
|
+
const ticker = await resolveTickerForCli(symbol, store, dataProvider);
|
|
336
|
+
const shares = parseFiniteNumber(sharesValue, "Shares");
|
|
337
|
+
const avgCost = parseFiniteNumber(avgCostValue, "Average cost");
|
|
338
|
+
const currency = resolveManualPositionCurrency(rawCurrency, ticker, portfolio, config.baseCurrency);
|
|
339
|
+
const result = setManualPortfolioPosition(ticker, portfolio.id, {
|
|
340
|
+
shares,
|
|
341
|
+
avgCost,
|
|
342
|
+
currency,
|
|
343
|
+
});
|
|
344
|
+
await store.saveTicker(result.ticker);
|
|
345
|
+
console.log(cliStyles.success(`Set position for ${result.ticker.metadata.ticker} in "${portfolio.name}".`));
|
|
346
|
+
console.log(renderStat("Shares", String(shares)));
|
|
347
|
+
console.log(renderStat("Average Cost", formatCurrency(avgCost, currency)));
|
|
348
|
+
console.log(renderStat("Currency", currency));
|
|
349
|
+
} catch (error: any) {
|
|
350
|
+
ctx.closeAndFail(persistence, error?.message || `Failed to set position for ${symbol} in "${portfolioName}".`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
persistence.close();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function createWatchlist(name: string, ctx: CliCommandContext) {
|
|
357
|
+
const { config, persistence } = await ctx.initConfigData();
|
|
358
|
+
const trimmedName = name.trim();
|
|
359
|
+
if (!trimmedName) {
|
|
360
|
+
ctx.closeAndFail(persistence, "Usage: gloomberb watchlist create <name>");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const id = slugifyName(trimmedName, "watchlist");
|
|
364
|
+
const duplicate = config.watchlists.some((watchlist) =>
|
|
365
|
+
watchlist.id === id || watchlist.name.toLowerCase() === trimmedName.toLowerCase()
|
|
366
|
+
);
|
|
367
|
+
if (duplicate) {
|
|
368
|
+
ctx.closeAndFail(persistence, `Watchlist "${trimmedName}" already exists.`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const nextConfig = {
|
|
372
|
+
...config,
|
|
373
|
+
watchlists: [...config.watchlists, { id, name: trimmedName }],
|
|
374
|
+
};
|
|
375
|
+
await saveConfig(nextConfig);
|
|
376
|
+
console.log(cliStyles.success(`Created watchlist "${trimmedName}".`));
|
|
377
|
+
console.log(renderStat("ID", id));
|
|
378
|
+
persistence.close();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function deleteWatchlist(name: string, ctx: CliCommandContext) {
|
|
382
|
+
const { config, store, persistence } = await ctx.initConfigData();
|
|
383
|
+
const watchlist = findWatchlist(config, name);
|
|
384
|
+
if (!watchlist) {
|
|
385
|
+
ctx.closeAndFail(persistence, `Watchlist "${name}" was not found.`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const nextConfig = {
|
|
389
|
+
...config,
|
|
390
|
+
watchlists: config.watchlists.filter((entry) => entry.id !== watchlist.id),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
let cleanedTickers = 0;
|
|
394
|
+
for (const ticker of await store.loadAllTickers()) {
|
|
395
|
+
if (!ticker.metadata.watchlists.includes(watchlist.id)) continue;
|
|
396
|
+
const nextTicker: TickerRecord = {
|
|
397
|
+
...ticker,
|
|
398
|
+
metadata: {
|
|
399
|
+
...ticker.metadata,
|
|
400
|
+
watchlists: ticker.metadata.watchlists.filter((entry) => entry !== watchlist.id),
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
await store.saveTicker(nextTicker);
|
|
404
|
+
cleanedTickers += 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
await saveConfig(nextConfig);
|
|
408
|
+
console.log(cliStyles.success(`Deleted watchlist "${watchlist.name}".`));
|
|
409
|
+
console.log(renderStat("Cleaned Tickers", String(cleanedTickers)));
|
|
410
|
+
persistence.close();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function addTickerToWatchlist(watchlistName: string, symbol: string, ctx: CliCommandContext) {
|
|
414
|
+
const { config, store, dataProvider, persistence } = await ctx.initMarketData();
|
|
415
|
+
const watchlist = findWatchlist(config, watchlistName);
|
|
416
|
+
if (!watchlist) {
|
|
417
|
+
ctx.closeAndFail(persistence, `Watchlist "${watchlistName}" was not found.`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const ticker = await resolveTickerForCli(symbol, store, dataProvider);
|
|
422
|
+
if (ticker.metadata.watchlists.includes(watchlist.id)) {
|
|
423
|
+
console.log(cliStyles.warning(`${ticker.metadata.ticker} is already in "${watchlist.name}".`));
|
|
424
|
+
persistence.close();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const nextTicker: TickerRecord = {
|
|
429
|
+
...ticker,
|
|
430
|
+
metadata: {
|
|
431
|
+
...ticker.metadata,
|
|
432
|
+
watchlists: [...ticker.metadata.watchlists, watchlist.id],
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
await store.saveTicker(nextTicker);
|
|
436
|
+
console.log(cliStyles.success(`Added ${nextTicker.metadata.ticker} to "${watchlist.name}".`));
|
|
437
|
+
if (nextTicker.metadata.name) {
|
|
438
|
+
console.log(renderStat("Name", nextTicker.metadata.name));
|
|
439
|
+
}
|
|
440
|
+
} catch (error: any) {
|
|
441
|
+
ctx.closeAndFail(persistence, error?.message || `Failed to add ${symbol} to "${watchlist.name}".`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
persistence.close();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function removeTickerFromWatchlist(watchlistName: string, symbol: string, ctx: CliCommandContext) {
|
|
448
|
+
const { config, store, persistence } = await ctx.initConfigData();
|
|
449
|
+
const watchlist = findWatchlist(config, watchlistName);
|
|
450
|
+
if (!watchlist) {
|
|
451
|
+
ctx.closeAndFail(persistence, `Watchlist "${watchlistName}" was not found.`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const normalized = symbol.trim().toUpperCase();
|
|
455
|
+
const ticker = await store.loadTicker(normalized);
|
|
456
|
+
if (!ticker) {
|
|
457
|
+
ctx.closeAndFail(persistence, `Ticker "${normalized}" was not found in your local data.`);
|
|
458
|
+
}
|
|
459
|
+
if (!ticker.metadata.watchlists.includes(watchlist.id)) {
|
|
460
|
+
ctx.closeAndFail(persistence, `${normalized} is not in "${watchlist.name}".`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const nextTicker: TickerRecord = {
|
|
464
|
+
...ticker,
|
|
465
|
+
metadata: {
|
|
466
|
+
...ticker.metadata,
|
|
467
|
+
watchlists: ticker.metadata.watchlists.filter((entry) => entry !== watchlist.id),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
await store.saveTicker(nextTicker);
|
|
471
|
+
console.log(cliStyles.success(`Removed ${normalized} from "${watchlist.name}".`));
|
|
472
|
+
persistence.close();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function listWatchlists(ctx: CliCommandContext) {
|
|
476
|
+
const { config, store, persistence } = await ctx.initConfigData();
|
|
477
|
+
const tickers = await store.loadAllTickers();
|
|
478
|
+
|
|
479
|
+
console.log(renderSection("Watchlists"));
|
|
480
|
+
if (config.watchlists.length === 0) {
|
|
481
|
+
console.log(cliStyles.muted("No watchlists configured."));
|
|
482
|
+
persistence.close();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
console.log(renderTable(
|
|
487
|
+
[
|
|
488
|
+
{ header: "Watchlist" },
|
|
489
|
+
{ header: "ID" },
|
|
490
|
+
{ header: "Tickers", align: "right" },
|
|
491
|
+
],
|
|
492
|
+
config.watchlists.map((watchlist) => [
|
|
493
|
+
watchlist.name,
|
|
494
|
+
watchlist.id,
|
|
495
|
+
String(countCollectionTickers(tickers, "watchlists", watchlist.id)),
|
|
496
|
+
]),
|
|
497
|
+
));
|
|
498
|
+
persistence.close();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export const portfolioCliCommand: CliCommandDef = {
|
|
502
|
+
name: "portfolio",
|
|
503
|
+
description: "List, inspect, create, delete, and manage manual portfolios",
|
|
504
|
+
help: {
|
|
505
|
+
usage: ["portfolio [action]"],
|
|
506
|
+
sections: [{
|
|
507
|
+
title: "Portfolio Actions",
|
|
508
|
+
columns: [
|
|
509
|
+
{ header: "Action" },
|
|
510
|
+
{ header: "Example" },
|
|
511
|
+
],
|
|
512
|
+
rows: [
|
|
513
|
+
["list", "gloomberb portfolio list"],
|
|
514
|
+
["show", "gloomberb portfolio show Research"],
|
|
515
|
+
["show (legacy)", "gloomberb portfolio Research"],
|
|
516
|
+
["create", "gloomberb portfolio create Research"],
|
|
517
|
+
["delete", "gloomberb portfolio delete Research"],
|
|
518
|
+
["add", "gloomberb portfolio add Research ASML"],
|
|
519
|
+
["remove", "gloomberb portfolio remove Research ASML"],
|
|
520
|
+
["position set", "gloomberb portfolio position set Research ASML 10 800 EUR"],
|
|
521
|
+
],
|
|
522
|
+
}],
|
|
523
|
+
},
|
|
524
|
+
execute: async (args, ctx) => {
|
|
525
|
+
const action = args[0];
|
|
526
|
+
|
|
527
|
+
if (!action || action === "list") {
|
|
528
|
+
await listCollections(ctx);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (action === "show") {
|
|
533
|
+
const name = args.slice(1).join(" ");
|
|
534
|
+
if (!name) ctx.fail("Usage: gloomberb portfolio show <name>");
|
|
535
|
+
await showCollection(name, ctx);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (action === "create") {
|
|
540
|
+
const name = args.slice(1).join(" ");
|
|
541
|
+
if (!name) ctx.fail("Usage: gloomberb portfolio create <name>");
|
|
542
|
+
await createPortfolioCommand(name, ctx);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (action === "delete" || action === "rm") {
|
|
547
|
+
const name = args.slice(1).join(" ");
|
|
548
|
+
if (!name) ctx.fail("Usage: gloomberb portfolio delete <name>");
|
|
549
|
+
await deletePortfolioCommand(name, ctx);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (action === "add") {
|
|
554
|
+
const symbol = args.at(-1);
|
|
555
|
+
const name = args.slice(1, -1).join(" ");
|
|
556
|
+
if (!name || !symbol) ctx.fail("Usage: gloomberb portfolio add <portfolio> <ticker>");
|
|
557
|
+
await addTickerToPortfolioCommand(name!, symbol!, ctx);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (action === "remove") {
|
|
562
|
+
const symbol = args.at(-1);
|
|
563
|
+
const name = args.slice(1, -1).join(" ");
|
|
564
|
+
if (!name || !symbol) ctx.fail("Usage: gloomberb portfolio remove <portfolio> <ticker>");
|
|
565
|
+
await removeTickerFromPortfolioCommand(name!, symbol!, ctx);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (action === "position") {
|
|
570
|
+
const subaction = args[1];
|
|
571
|
+
if (subaction !== "set") {
|
|
572
|
+
ctx.fail("Usage: gloomberb portfolio position set <portfolio> <ticker> <shares> <avg-cost> [currency]");
|
|
573
|
+
}
|
|
574
|
+
const [, , ...rest] = args;
|
|
575
|
+
const currency = rest[4];
|
|
576
|
+
if (rest.length < 4) {
|
|
577
|
+
ctx.fail("Usage: gloomberb portfolio position set <portfolio> <ticker> <shares> <avg-cost> [currency]");
|
|
578
|
+
}
|
|
579
|
+
await setPositionCommand(rest[0]!, rest[1]!, rest[2]!, rest[3]!, currency, ctx);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
await showCollection(args.join(" "), ctx);
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export const watchlistCliCommand: CliCommandDef = {
|
|
588
|
+
name: "watchlist",
|
|
589
|
+
aliases: ["watchlists"],
|
|
590
|
+
description: "List, create, delete, add, or remove watchlists",
|
|
591
|
+
help: {
|
|
592
|
+
usage: ["watchlist [action]"],
|
|
593
|
+
sections: [{
|
|
594
|
+
title: "Watchlist Actions",
|
|
595
|
+
columns: [
|
|
596
|
+
{ header: "Action" },
|
|
597
|
+
{ header: "Example" },
|
|
598
|
+
],
|
|
599
|
+
rows: [
|
|
600
|
+
["list", "gloomberb watchlist list"],
|
|
601
|
+
["show", "gloomberb watchlist show Growth"],
|
|
602
|
+
["create", "gloomberb watchlist create Growth"],
|
|
603
|
+
["delete", "gloomberb watchlist delete Growth"],
|
|
604
|
+
["add", "gloomberb watchlist add Growth NVDA"],
|
|
605
|
+
["remove", "gloomberb watchlist remove Growth NVDA"],
|
|
606
|
+
],
|
|
607
|
+
}],
|
|
608
|
+
},
|
|
609
|
+
execute: async (args, ctx) => {
|
|
610
|
+
const action = args[0];
|
|
611
|
+
|
|
612
|
+
if (!action || action === "list") {
|
|
613
|
+
await listWatchlists(ctx);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (action === "show") {
|
|
618
|
+
const name = args.slice(1).join(" ");
|
|
619
|
+
if (!name) ctx.fail("Usage: gloomberb watchlist show <name>");
|
|
620
|
+
await showCollection(name, ctx);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (action === "create") {
|
|
625
|
+
const name = args.slice(1).join(" ");
|
|
626
|
+
if (!name) ctx.fail("Usage: gloomberb watchlist create <name>");
|
|
627
|
+
await createWatchlist(name, ctx);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (action === "delete" || action === "rm") {
|
|
632
|
+
const name = args.slice(1).join(" ");
|
|
633
|
+
if (!name) ctx.fail("Usage: gloomberb watchlist delete <name>");
|
|
634
|
+
await deleteWatchlist(name, ctx);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (action === "add") {
|
|
639
|
+
const symbol = args.at(-1);
|
|
640
|
+
const name = args.slice(1, -1).join(" ");
|
|
641
|
+
if (!name || !symbol) ctx.fail("Usage: gloomberb watchlist add <watchlist> <ticker>");
|
|
642
|
+
await addTickerToWatchlist(name!, symbol!, ctx);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (action === "remove") {
|
|
647
|
+
const symbol = args.at(-1);
|
|
648
|
+
const name = args.slice(1, -1).join(" ");
|
|
649
|
+
if (!name || !symbol) ctx.fail("Usage: gloomberb watchlist remove <watchlist> <ticker>");
|
|
650
|
+
await removeTickerFromWatchlist(name!, symbol!, ctx);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
await showCollection(args.join(" "), ctx);
|
|
655
|
+
},
|
|
656
|
+
};
|