gloomberb 0.3.3 → 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 +27 -16
- 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-renderer.ts +7 -2
- 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/chart.test.ts +26 -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 +484 -112
- 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 +75 -1
- package/src/plugins/ibkr/gateway-service.ts +179 -9
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/chart.test.tsx +139 -0
- package/src/plugins/prediction-markets/chart.tsx +65 -436
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +17 -5
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
- package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
- package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
- package/src/plugins/prediction-markets/detail.test.tsx +4 -0
- 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 +160 -58
- package/src/plugins/prediction-markets/pane.tsx +74 -183
- 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 +308 -3
- package/src/sources/provider-router.ts +247 -17
- 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/price-history.test.ts +25 -0
- package/src/utils/price-history.ts +29 -5
- 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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { AppConfig } from "../types/config";
|
|
2
|
+
import type { CliCommandContext, CliCommandDef, CliDispatchResult, GloomPlugin } from "../types/plugin";
|
|
3
|
+
import type { LoadedExternalPlugin } from "../plugins/loader";
|
|
4
|
+
import { getPluginCatalog } from "../plugins/catalog";
|
|
5
|
+
import { initConfigData, initMarketData } from "./context";
|
|
6
|
+
import { closeAndFail, fail } from "./errors";
|
|
7
|
+
import {
|
|
8
|
+
cliStyles,
|
|
9
|
+
colorBySign,
|
|
10
|
+
renderSection,
|
|
11
|
+
renderStat,
|
|
12
|
+
renderTable,
|
|
13
|
+
} from "../utils/cli-output";
|
|
14
|
+
import { debugLog } from "../utils/debug-log";
|
|
15
|
+
|
|
16
|
+
const registryLog = debugLog.createLogger("cli-registry");
|
|
17
|
+
|
|
18
|
+
export interface RegisteredCliCommand {
|
|
19
|
+
command: CliCommandDef;
|
|
20
|
+
ownerId: string;
|
|
21
|
+
source: "core" | "plugin";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliCommandRegistry {
|
|
25
|
+
commands: RegisteredCliCommand[];
|
|
26
|
+
lookup: ReadonlyMap<string, RegisteredCliCommand>;
|
|
27
|
+
config: AppConfig | null;
|
|
28
|
+
plugins: GloomPlugin[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BuildCliCommandRegistryOptions {
|
|
32
|
+
coreCommands: CliCommandDef[];
|
|
33
|
+
externalPlugins?: LoadedExternalPlugin[];
|
|
34
|
+
config?: AppConfig | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeCliCommandToken(token: string): string {
|
|
38
|
+
return token.trim().toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function describeCommandOwner(ownerId: string, source: "core" | "plugin"): string {
|
|
42
|
+
return source === "core" ? `core CLI (${ownerId})` : `plugin "${ownerId}"`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validateCommandToken(token: string, ownerId: string, source: "core" | "plugin"): string {
|
|
46
|
+
const normalized = normalizeCliCommandToken(token);
|
|
47
|
+
if (!normalized) {
|
|
48
|
+
throw new Error(`CLI command token for ${describeCommandOwner(ownerId, source)} cannot be empty.`);
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeCliDispatchResult(result: void | CliDispatchResult): CliDispatchResult {
|
|
54
|
+
if (!result) {
|
|
55
|
+
return { kind: "handled" };
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getCommandUsageLabel(command: CliCommandDef): string {
|
|
61
|
+
return command.help?.usage?.[0] ?? command.name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderHelpSections(registry: CliCommandRegistry): string[] {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
|
|
67
|
+
for (const { command } of registry.commands) {
|
|
68
|
+
const sections = command.help?.sections ?? [];
|
|
69
|
+
for (const section of sections) {
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push(renderSection(section.title));
|
|
72
|
+
if (section.lines && section.lines.length > 0) {
|
|
73
|
+
lines.push(...section.lines);
|
|
74
|
+
}
|
|
75
|
+
if (section.columns && section.rows) {
|
|
76
|
+
lines.push(renderTable(section.columns, section.rows));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderCliHelp(registry: CliCommandRegistry, version: string): string {
|
|
85
|
+
const lines = [
|
|
86
|
+
`${cliStyles.bold(`gloomberb v${version}`)}\n${cliStyles.muted("Bloomberg-style portfolio tracker for the terminal")}`,
|
|
87
|
+
"",
|
|
88
|
+
renderSection("Usage"),
|
|
89
|
+
"gloomberb [command]",
|
|
90
|
+
"",
|
|
91
|
+
renderSection("Commands"),
|
|
92
|
+
renderTable(
|
|
93
|
+
[
|
|
94
|
+
{ header: "Command" },
|
|
95
|
+
{ header: "Description" },
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
["(no command)", "Launch the terminal UI"],
|
|
99
|
+
...registry.commands.map(({ command }) => [
|
|
100
|
+
getCommandUsageLabel(command),
|
|
101
|
+
command.description,
|
|
102
|
+
]),
|
|
103
|
+
],
|
|
104
|
+
),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
lines.push(...renderHelpSections(registry));
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildCliCommandRegistry({
|
|
112
|
+
coreCommands,
|
|
113
|
+
externalPlugins = [],
|
|
114
|
+
config = null,
|
|
115
|
+
}: BuildCliCommandRegistryOptions): CliCommandRegistry {
|
|
116
|
+
const allCommands: RegisteredCliCommand[] = [];
|
|
117
|
+
const allTokens = new Map<string, RegisteredCliCommand>();
|
|
118
|
+
const catalog = getPluginCatalog(externalPlugins);
|
|
119
|
+
const disabledPlugins = new Set(config?.disabledPlugins ?? []);
|
|
120
|
+
|
|
121
|
+
const registerCommand = (
|
|
122
|
+
command: CliCommandDef,
|
|
123
|
+
ownerId: string,
|
|
124
|
+
source: "core" | "plugin",
|
|
125
|
+
) => {
|
|
126
|
+
const tokens = new Set([
|
|
127
|
+
validateCommandToken(command.name, ownerId, source),
|
|
128
|
+
...(command.aliases ?? []).map((alias) => validateCommandToken(alias, ownerId, source)),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
for (const token of tokens) {
|
|
132
|
+
const existing = allTokens.get(token);
|
|
133
|
+
if (!existing) continue;
|
|
134
|
+
throw new Error(
|
|
135
|
+
`CLI command token "${token}" is declared by both `
|
|
136
|
+
+ `${describeCommandOwner(existing.ownerId, existing.source)} and ${describeCommandOwner(ownerId, source)}.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const entry: RegisteredCliCommand = { command, ownerId, source };
|
|
141
|
+
allCommands.push(entry);
|
|
142
|
+
for (const token of tokens) {
|
|
143
|
+
allTokens.set(token, entry);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const command of coreCommands) {
|
|
148
|
+
registerCommand(command, "core", "core");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const loadablePlugins: GloomPlugin[] = [];
|
|
152
|
+
for (const entry of catalog) {
|
|
153
|
+
if (entry.error) {
|
|
154
|
+
registryLog.warn(`Skipping external plugin "${entry.plugin.id}" for CLI registration.`, {
|
|
155
|
+
path: entry.path,
|
|
156
|
+
error: entry.error,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
loadablePlugins.push(entry.plugin);
|
|
161
|
+
for (const command of entry.plugin.cliCommands ?? []) {
|
|
162
|
+
registerCommand(command, entry.plugin.id, "plugin");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const commands = allCommands.filter((entry) => (
|
|
167
|
+
entry.source === "core" || !disabledPlugins.has(entry.ownerId)
|
|
168
|
+
));
|
|
169
|
+
|
|
170
|
+
const lookup = new Map<string, RegisteredCliCommand>();
|
|
171
|
+
for (const entry of commands) {
|
|
172
|
+
lookup.set(normalizeCliCommandToken(entry.command.name), entry);
|
|
173
|
+
for (const alias of entry.command.aliases ?? []) {
|
|
174
|
+
lookup.set(normalizeCliCommandToken(alias), entry);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
commands,
|
|
180
|
+
lookup,
|
|
181
|
+
config,
|
|
182
|
+
plugins: loadablePlugins,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function createCliCommandContext(ownerId: string, plugins: GloomPlugin[]): CliCommandContext {
|
|
187
|
+
return {
|
|
188
|
+
initConfigData,
|
|
189
|
+
initMarketData: () => initMarketData({ plugins }),
|
|
190
|
+
fail,
|
|
191
|
+
closeAndFail,
|
|
192
|
+
output: {
|
|
193
|
+
cliStyles,
|
|
194
|
+
colorBySign,
|
|
195
|
+
renderSection,
|
|
196
|
+
renderStat,
|
|
197
|
+
renderTable,
|
|
198
|
+
},
|
|
199
|
+
log: debugLog.createLogger(ownerId === "core" ? "cli" : ownerId),
|
|
200
|
+
};
|
|
201
|
+
}
|
package/src/cli.test.ts
CHANGED
|
@@ -32,11 +32,15 @@ async function createTempDir(prefix: string): Promise<string> {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
async function createCliFixture({
|
|
35
|
+
portfolios,
|
|
35
36
|
watchlists,
|
|
36
37
|
tickers = [],
|
|
38
|
+
baseCurrency,
|
|
37
39
|
}: {
|
|
40
|
+
portfolios?: Array<{ id: string; name: string; currency: string; brokerId?: string; brokerInstanceId?: string; brokerAccountId?: string }>;
|
|
38
41
|
watchlists: Array<{ id: string; name: string }>;
|
|
39
42
|
tickers?: TickerRecord[];
|
|
43
|
+
baseCurrency?: string;
|
|
40
44
|
}) {
|
|
41
45
|
const homeDir = await createTempDir("gloomberb-cli-home-");
|
|
42
46
|
const dataDir = await createTempDir("gloomberb-cli-data-");
|
|
@@ -46,6 +50,12 @@ async function createCliFixture({
|
|
|
46
50
|
await writeFile(join(homeDir, ".gloomberb", "config.json"), JSON.stringify({ dataDir }), "utf-8");
|
|
47
51
|
|
|
48
52
|
const config = createDefaultConfig(dataDir);
|
|
53
|
+
if (baseCurrency) {
|
|
54
|
+
config.baseCurrency = baseCurrency;
|
|
55
|
+
}
|
|
56
|
+
if (portfolios) {
|
|
57
|
+
config.portfolios = portfolios;
|
|
58
|
+
}
|
|
49
59
|
config.watchlists = watchlists;
|
|
50
60
|
await saveConfig(config);
|
|
51
61
|
|
|
@@ -85,6 +95,39 @@ async function captureConsole<T>(fn: () => Promise<T> | T): Promise<{ result: T;
|
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
async function captureConsoleFailure(fn: () => Promise<unknown> | unknown): Promise<{ stdout: string; stderr: string; error: unknown }> {
|
|
99
|
+
const logs: string[] = [];
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
const originalLog = console.log;
|
|
102
|
+
const originalError = console.error;
|
|
103
|
+
const originalExit = process.exit;
|
|
104
|
+
|
|
105
|
+
console.log = (...args: unknown[]) => {
|
|
106
|
+
logs.push(args.map(String).join(" "));
|
|
107
|
+
};
|
|
108
|
+
console.error = (...args: unknown[]) => {
|
|
109
|
+
errors.push(args.map(String).join(" "));
|
|
110
|
+
};
|
|
111
|
+
process.exit = ((code?: number) => {
|
|
112
|
+
throw new Error(`process.exit:${code ?? 0}`);
|
|
113
|
+
}) as typeof process.exit;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await fn();
|
|
117
|
+
throw new Error("Expected command to fail.");
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return {
|
|
120
|
+
stdout: logs.join("\n"),
|
|
121
|
+
stderr: errors.join("\n"),
|
|
122
|
+
error,
|
|
123
|
+
};
|
|
124
|
+
} finally {
|
|
125
|
+
console.log = originalLog;
|
|
126
|
+
console.error = originalError;
|
|
127
|
+
process.exit = originalExit;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
88
131
|
function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRecord {
|
|
89
132
|
return {
|
|
90
133
|
metadata: {
|
|
@@ -110,6 +153,8 @@ describe("CLI watchlist commands", () => {
|
|
|
110
153
|
expect(stdout).toContain("predictions [...]");
|
|
111
154
|
expect(stdout).toContain("Prediction Launch");
|
|
112
155
|
expect(stdout).toContain("gloomberb predictions world");
|
|
156
|
+
expect(stdout).toContain("Portfolio Actions");
|
|
157
|
+
expect(stdout).toContain("Watchlist Actions");
|
|
113
158
|
});
|
|
114
159
|
|
|
115
160
|
test("creates a watchlist and persists the generated id", async () => {
|
|
@@ -155,6 +200,180 @@ describe("CLI watchlist commands", () => {
|
|
|
155
200
|
});
|
|
156
201
|
});
|
|
157
202
|
|
|
203
|
+
describe("CLI portfolio commands", () => {
|
|
204
|
+
test("creates a manual portfolio and persists the generated id", async () => {
|
|
205
|
+
const { dataDir } = await createCliFixture({
|
|
206
|
+
portfolios: [{ id: "main", name: "Main Portfolio", currency: "USD" }],
|
|
207
|
+
watchlists: [],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const { stdout } = await captureConsole(() => runCli(["portfolio", "create", "Research"]));
|
|
211
|
+
const config = await loadConfig(dataDir);
|
|
212
|
+
|
|
213
|
+
expect(config.portfolios).toEqual([
|
|
214
|
+
{ id: "main", name: "Main Portfolio", currency: "USD" },
|
|
215
|
+
{ id: "research", name: "Research", currency: "USD" },
|
|
216
|
+
]);
|
|
217
|
+
expect(stdout).toContain('Created portfolio "Research".');
|
|
218
|
+
expect(stdout).toContain("research");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("rejects duplicate manual portfolio names", async () => {
|
|
222
|
+
await createCliFixture({
|
|
223
|
+
portfolios: [{ id: "research", name: "Research", currency: "USD" }],
|
|
224
|
+
watchlists: [],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = await captureConsoleFailure(() => runCli(["portfolio", "create", "Research"]));
|
|
228
|
+
expect(String(result.error)).toContain("process.exit:1");
|
|
229
|
+
expect(result.stderr).toContain('Portfolio "Research" already exists.');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("adds a ticker to a manual portfolio and supports legacy show", async () => {
|
|
233
|
+
const { dataDir } = await createCliFixture({
|
|
234
|
+
portfolios: [{ id: "research", name: "Research", currency: "USD" }],
|
|
235
|
+
watchlists: [],
|
|
236
|
+
tickers: [makeTicker()],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const addResult = await captureConsole(() => runCli(["portfolio", "add", "Research", "NVDA"]));
|
|
240
|
+
expect(addResult.stdout).toContain('Added NVDA to "Research".');
|
|
241
|
+
|
|
242
|
+
let persistence = new AppPersistence(join(dataDir, ".gloomberb-cache.db"));
|
|
243
|
+
let store = new TickerRepository(persistence.tickers);
|
|
244
|
+
let savedTicker = await store.loadTicker("NVDA");
|
|
245
|
+
persistence.close();
|
|
246
|
+
|
|
247
|
+
expect(savedTicker?.metadata.portfolios).toEqual(["research"]);
|
|
248
|
+
|
|
249
|
+
const showResult = await captureConsole(() => runCli(["portfolio", "Research"]));
|
|
250
|
+
expect(showResult.stdout).toContain("Research (USD)");
|
|
251
|
+
expect(showResult.stdout).toContain("NVDA");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("sets and replaces a manual position for a portfolio ticker", async () => {
|
|
255
|
+
const { dataDir } = await createCliFixture({
|
|
256
|
+
portfolios: [{ id: "research", name: "Research", currency: "USD" }],
|
|
257
|
+
watchlists: [],
|
|
258
|
+
tickers: [makeTicker()],
|
|
259
|
+
baseCurrency: "USD",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const first = await captureConsole(() => runCli(["portfolio", "position", "set", "Research", "NVDA", "10", "400"]));
|
|
263
|
+
expect(first.stdout).toContain('Set position for NVDA in "Research".');
|
|
264
|
+
expect(first.stdout).toContain("Shares");
|
|
265
|
+
expect(first.stdout).toContain("10");
|
|
266
|
+
|
|
267
|
+
const second = await captureConsole(() => runCli(["portfolio", "position", "set", "Research", "NVDA", "12", "405", "EUR"]));
|
|
268
|
+
expect(second.stdout).toContain("EUR");
|
|
269
|
+
|
|
270
|
+
const persistence = new AppPersistence(join(dataDir, ".gloomberb-cache.db"));
|
|
271
|
+
const store = new TickerRepository(persistence.tickers);
|
|
272
|
+
const savedTicker = await store.loadTicker("NVDA");
|
|
273
|
+
persistence.close();
|
|
274
|
+
|
|
275
|
+
expect(savedTicker?.metadata.portfolios).toEqual(["research"]);
|
|
276
|
+
expect(savedTicker?.metadata.positions).toEqual([{
|
|
277
|
+
portfolio: "research",
|
|
278
|
+
shares: 12,
|
|
279
|
+
avgCost: 405,
|
|
280
|
+
currency: "EUR",
|
|
281
|
+
broker: "manual",
|
|
282
|
+
}]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("removes portfolio membership and positions, then cleans all references on delete", async () => {
|
|
286
|
+
const { dataDir } = await createCliFixture({
|
|
287
|
+
portfolios: [
|
|
288
|
+
{ id: "main", name: "Main Portfolio", currency: "USD" },
|
|
289
|
+
{ id: "research", name: "Research", currency: "USD" },
|
|
290
|
+
],
|
|
291
|
+
watchlists: [],
|
|
292
|
+
tickers: [
|
|
293
|
+
makeTicker({
|
|
294
|
+
ticker: "NVDA",
|
|
295
|
+
portfolios: ["research"],
|
|
296
|
+
positions: [{
|
|
297
|
+
portfolio: "research",
|
|
298
|
+
shares: 5,
|
|
299
|
+
avgCost: 350,
|
|
300
|
+
currency: "USD",
|
|
301
|
+
broker: "manual",
|
|
302
|
+
}],
|
|
303
|
+
}),
|
|
304
|
+
makeTicker({
|
|
305
|
+
ticker: "ASML",
|
|
306
|
+
portfolios: ["research", "main"],
|
|
307
|
+
positions: [{
|
|
308
|
+
portfolio: "research",
|
|
309
|
+
shares: 3,
|
|
310
|
+
avgCost: 700,
|
|
311
|
+
currency: "USD",
|
|
312
|
+
broker: "manual",
|
|
313
|
+
}, {
|
|
314
|
+
portfolio: "main",
|
|
315
|
+
shares: 1,
|
|
316
|
+
avgCost: 650,
|
|
317
|
+
currency: "USD",
|
|
318
|
+
broker: "manual",
|
|
319
|
+
}],
|
|
320
|
+
}),
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const removeResult = await captureConsole(() => runCli(["portfolio", "remove", "Research", "NVDA"]));
|
|
325
|
+
expect(removeResult.stdout).toContain('Removed NVDA from "Research".');
|
|
326
|
+
expect(removeResult.stdout).toContain("Removed Positions");
|
|
327
|
+
|
|
328
|
+
let persistence = new AppPersistence(join(dataDir, ".gloomberb-cache.db"));
|
|
329
|
+
let store = new TickerRepository(persistence.tickers);
|
|
330
|
+
let savedTicker = await store.loadTicker("NVDA");
|
|
331
|
+
persistence.close();
|
|
332
|
+
|
|
333
|
+
expect(savedTicker?.metadata.portfolios).toEqual([]);
|
|
334
|
+
expect(savedTicker?.metadata.positions).toEqual([]);
|
|
335
|
+
|
|
336
|
+
const deleteResult = await captureConsole(() => runCli(["portfolio", "delete", "Research"]));
|
|
337
|
+
expect(deleteResult.stdout).toContain('Deleted portfolio "Research".');
|
|
338
|
+
expect(deleteResult.stdout).toContain("Removed Positions");
|
|
339
|
+
|
|
340
|
+
const config = await loadConfig(dataDir);
|
|
341
|
+
expect(config.portfolios).toEqual([{ id: "main", name: "Main Portfolio", currency: "USD" }]);
|
|
342
|
+
|
|
343
|
+
persistence = new AppPersistence(join(dataDir, ".gloomberb-cache.db"));
|
|
344
|
+
store = new TickerRepository(persistence.tickers);
|
|
345
|
+
savedTicker = await store.loadTicker("ASML");
|
|
346
|
+
persistence.close();
|
|
347
|
+
|
|
348
|
+
expect(savedTicker?.metadata.portfolios).toEqual(["main"]);
|
|
349
|
+
expect(savedTicker?.metadata.positions).toEqual([{
|
|
350
|
+
portfolio: "main",
|
|
351
|
+
shares: 1,
|
|
352
|
+
avgCost: 650,
|
|
353
|
+
currency: "USD",
|
|
354
|
+
broker: "manual",
|
|
355
|
+
}]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("rejects broker-managed portfolio mutations", async () => {
|
|
359
|
+
await createCliFixture({
|
|
360
|
+
portfolios: [{
|
|
361
|
+
id: "broker:ibkr:acct",
|
|
362
|
+
name: "IBKR Account",
|
|
363
|
+
currency: "USD",
|
|
364
|
+
brokerId: "ibkr",
|
|
365
|
+
brokerInstanceId: "ibkr-live",
|
|
366
|
+
}],
|
|
367
|
+
watchlists: [],
|
|
368
|
+
tickers: [makeTicker()],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const result = await captureConsoleFailure(() => runCli(["portfolio", "add", "IBKR Account", "NVDA"]));
|
|
372
|
+
expect(String(result.error)).toContain("process.exit:1");
|
|
373
|
+
expect(result.stderr).toContain('Portfolio "IBKR Account" is broker-managed and cannot be modified manually.');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
158
377
|
describe("buildTickerReport", () => {
|
|
159
378
|
test("includes deeper fundamentals and recent statement sections", async () => {
|
|
160
379
|
const config = createDefaultConfig("/tmp/gloomberb-cli-report");
|