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.
Files changed (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,210 +0,0 @@
1
- import { formatCompact, formatCurrency } from "../../utils/format";
2
- import {
3
- cliStyles,
4
- colorBySign,
5
- renderSection,
6
- renderStat,
7
- renderTable,
8
- } from "../../utils/cli-output";
9
- import { exchangeShortName } from "../../utils/market-status";
10
- import type { AppConfig } from "../../types/config";
11
- import type { TickerRecord } from "../../types/ticker";
12
- import { createBaseConverter, initConfigData, initMarketData } from "../context";
13
- import { closeAndFail } from "../errors";
14
- import {
15
- countCollectionTickers,
16
- formatSignedCurrency,
17
- formatSignedPercentRaw,
18
- } from "../helpers";
19
-
20
- function renderCollectionOverview(config: AppConfig, tickers: TickerRecord[]): string {
21
- const blocks: string[] = [];
22
-
23
- blocks.push(renderSection("Portfolios"));
24
- if (config.portfolios.length === 0) {
25
- blocks.push(cliStyles.muted("No portfolios configured."));
26
- } else {
27
- blocks.push(renderTable(
28
- [
29
- { header: "Portfolio" },
30
- { header: "Currency" },
31
- { header: "Tickers", align: "right" },
32
- ],
33
- config.portfolios.map((portfolio) => [
34
- portfolio.name,
35
- portfolio.currency,
36
- String(countCollectionTickers(tickers, "portfolios", portfolio.id)),
37
- ]),
38
- ));
39
- }
40
-
41
- blocks.push("");
42
- blocks.push(renderSection("Watchlists"));
43
- if (config.watchlists.length === 0) {
44
- blocks.push(cliStyles.muted("No watchlists configured."));
45
- } else {
46
- blocks.push(renderTable(
47
- [
48
- { header: "Watchlist" },
49
- { header: "Tickers", align: "right" },
50
- ],
51
- config.watchlists.map((watchlist) => [
52
- watchlist.name,
53
- String(countCollectionTickers(tickers, "watchlists", watchlist.id)),
54
- ]),
55
- ));
56
- }
57
-
58
- return blocks.join("\n");
59
- }
60
-
61
- export async function showCollection(name: string) {
62
- const { config, store, dataProvider, persistence } = await initMarketData();
63
- const tickers = (await store.loadAllTickers()).sort((left, right) =>
64
- left.metadata.ticker.localeCompare(right.metadata.ticker)
65
- );
66
- const baseCurrency = config.baseCurrency;
67
- const toBase = createBaseConverter(dataProvider, baseCurrency);
68
-
69
- const normalized = name.trim().toLowerCase();
70
- const matchedPortfolio = config.portfolios.find((portfolio) =>
71
- portfolio.id.toLowerCase() === normalized || portfolio.name.toLowerCase() === normalized
72
- );
73
- const matchedWatchlist = config.watchlists.find((watchlist) =>
74
- watchlist.id.toLowerCase() === normalized || watchlist.name.toLowerCase() === normalized
75
- );
76
-
77
- if (!matchedPortfolio && !matchedWatchlist) {
78
- closeAndFail(
79
- persistence,
80
- `Collection "${name}" was not found.`,
81
- `Available: ${[...config.portfolios.map((portfolio) => portfolio.name), ...config.watchlists.map((watchlist) => watchlist.name)].join(", ")}`,
82
- );
83
- }
84
-
85
- const isPortfolio = !!matchedPortfolio;
86
- const id = matchedPortfolio?.id ?? matchedWatchlist!.id;
87
- const displayName = matchedPortfolio?.name ?? matchedWatchlist!.name;
88
- const currency = matchedPortfolio?.currency ?? baseCurrency;
89
- const filtered = tickers.filter((ticker) =>
90
- isPortfolio ? ticker.metadata.portfolios.includes(id) : ticker.metadata.watchlists.includes(id)
91
- );
92
-
93
- if (filtered.length === 0) {
94
- console.log(cliStyles.bold(displayName));
95
- console.log(cliStyles.muted("No tickers in this collection."));
96
- persistence.close();
97
- return;
98
- }
99
-
100
- const quotes = new Map<string, Awaited<ReturnType<typeof dataProvider.getQuote>>>();
101
- await Promise.all(
102
- filtered.map(async (ticker) => {
103
- try {
104
- const quote = await dataProvider.getQuote(ticker.metadata.ticker, ticker.metadata.exchange);
105
- quotes.set(ticker.metadata.ticker, quote);
106
- } catch {
107
- // Ignore partial quote failures so the rest of the table still renders.
108
- }
109
- }),
110
- );
111
-
112
- console.log(cliStyles.bold(displayName + (isPortfolio ? ` (${currency})` : "")));
113
- console.log(cliStyles.muted(`${filtered.length} ticker${filtered.length === 1 ? "" : "s"}`));
114
- console.log("");
115
-
116
- if (isPortfolio) {
117
- let totalPnl = 0;
118
- const rows: string[][] = [];
119
-
120
- for (const ticker of filtered) {
121
- const quote = quotes.get(ticker.metadata.ticker);
122
- const positions = ticker.metadata.positions.filter((position) => position.portfolio === id);
123
- const priceText = quote ? colorBySign(formatCurrency(quote.price, quote.currency), quote.change) : "—";
124
- const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—";
125
-
126
- if (positions.length === 0) {
127
- rows.push([ticker.metadata.ticker, priceText, changeText, "—", "—", "—"]);
128
- continue;
129
- }
130
-
131
- for (const position of positions) {
132
- const multiplier = position.multiplier ?? 1;
133
- const quoteCurrency = quote?.currency ?? ticker.metadata.currency ?? baseCurrency;
134
- const positionCurrency = position.currency ?? quoteCurrency;
135
- const currentValueBase = quote
136
- ? await toBase(Math.abs(position.shares) * quote.price * multiplier, quoteCurrency)
137
- : null;
138
- const costBasisBase = await toBase(position.shares * position.avgCost * multiplier, positionCurrency);
139
- const pnl = currentValueBase != null ? currentValueBase - costBasisBase : null;
140
- if (pnl != null) totalPnl += pnl;
141
-
142
- rows.push([
143
- ticker.metadata.ticker,
144
- priceText,
145
- changeText,
146
- String(position.shares),
147
- formatCurrency(position.avgCost, positionCurrency),
148
- pnl == null ? "—" : colorBySign(formatSignedCurrency(pnl, baseCurrency), pnl),
149
- ]);
150
- }
151
- }
152
-
153
- console.log(renderTable(
154
- [
155
- { header: "Ticker" },
156
- { header: "Last", align: "right" },
157
- { header: "Chg", align: "right" },
158
- { header: "Shares", align: "right" },
159
- { header: "Avg Cost", align: "right" },
160
- { header: "P&L", align: "right" },
161
- ],
162
- rows,
163
- ));
164
- console.log("");
165
- console.log(renderStat("Total P&L", colorBySign(formatSignedCurrency(totalPnl, baseCurrency), totalPnl)));
166
- } else {
167
- const rows: string[][] = [];
168
- for (const ticker of filtered) {
169
- const quote = quotes.get(ticker.metadata.ticker);
170
- const priceText = quote ? colorBySign(formatCurrency(quote.price, quote.currency), quote.change) : "—";
171
- const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—";
172
- const marketCapText = quote?.marketCap != null
173
- ? `${formatCompact(await toBase(quote.marketCap, quote.currency || ticker.metadata.currency || baseCurrency))} ${baseCurrency}`
174
- : "—";
175
-
176
- rows.push([
177
- ticker.metadata.ticker,
178
- exchangeShortName(quote?.exchangeName, quote?.fullExchangeName) || ticker.metadata.exchange || "—",
179
- priceText,
180
- changeText,
181
- marketCapText,
182
- ]);
183
- }
184
-
185
- console.log(renderTable(
186
- [
187
- { header: "Ticker" },
188
- { header: "Exchange" },
189
- { header: "Last", align: "right" },
190
- { header: "Chg", align: "right" },
191
- { header: "Mkt Cap", align: "right" },
192
- ],
193
- rows,
194
- ));
195
- }
196
-
197
- persistence.close();
198
- }
199
-
200
- export async function portfolio(name?: string) {
201
- if (!name) {
202
- const { config, store, persistence } = await initConfigData();
203
- const tickers = await store.loadAllTickers();
204
- console.log(renderCollectionOverview(config, tickers));
205
- persistence.close();
206
- return;
207
- }
208
-
209
- await showCollection(name);
210
- }
@@ -1,210 +0,0 @@
1
- import { saveConfig } from "../../data/config-store";
2
- import {
3
- cliStyles,
4
- renderSection,
5
- renderStat,
6
- renderTable,
7
- } from "../../utils/cli-output";
8
- import type { TickerRecord } from "../../types/ticker";
9
- import { initConfigData, initMarketData } from "../context";
10
- import { closeAndFail, fail } from "../errors";
11
- import {
12
- countCollectionTickers,
13
- findWatchlist,
14
- slugifyName,
15
- } from "../helpers";
16
- import { resolveTickerForCli } from "../ticker-resolution";
17
- import { showCollection } from "./portfolio";
18
-
19
- async function createWatchlist(name: string) {
20
- const { config, persistence } = await initConfigData();
21
- const trimmedName = name.trim();
22
- if (!trimmedName) {
23
- closeAndFail(persistence, "Usage: gloomberb watchlist create <name>");
24
- }
25
-
26
- const id = slugifyName(trimmedName, "watchlist");
27
- const duplicate = config.watchlists.some((watchlist) =>
28
- watchlist.id === id || watchlist.name.toLowerCase() === trimmedName.toLowerCase()
29
- );
30
- if (duplicate) {
31
- closeAndFail(persistence, `Watchlist "${trimmedName}" already exists.`);
32
- }
33
-
34
- const nextConfig = {
35
- ...config,
36
- watchlists: [...config.watchlists, { id, name: trimmedName }],
37
- };
38
- await saveConfig(nextConfig);
39
- console.log(cliStyles.success(`Created watchlist "${trimmedName}".`));
40
- console.log(renderStat("ID", id));
41
- persistence.close();
42
- }
43
-
44
- async function deleteWatchlist(name: string) {
45
- const { config, store, persistence } = await initConfigData();
46
- const watchlist = findWatchlist(config, name);
47
- if (!watchlist) {
48
- closeAndFail(persistence, `Watchlist "${name}" was not found.`);
49
- }
50
-
51
- const nextConfig = {
52
- ...config,
53
- watchlists: config.watchlists.filter((entry) => entry.id !== watchlist.id),
54
- };
55
-
56
- let cleanedTickers = 0;
57
- for (const ticker of await store.loadAllTickers()) {
58
- if (!ticker.metadata.watchlists.includes(watchlist.id)) continue;
59
- const nextTicker: TickerRecord = {
60
- ...ticker,
61
- metadata: {
62
- ...ticker.metadata,
63
- watchlists: ticker.metadata.watchlists.filter((entry) => entry !== watchlist.id),
64
- },
65
- };
66
- await store.saveTicker(nextTicker);
67
- cleanedTickers += 1;
68
- }
69
-
70
- await saveConfig(nextConfig);
71
- console.log(cliStyles.success(`Deleted watchlist "${watchlist.name}".`));
72
- console.log(renderStat("Cleaned Tickers", String(cleanedTickers)));
73
- persistence.close();
74
- }
75
-
76
- async function addTickerToWatchlist(watchlistName: string, symbol: string) {
77
- const { config, store, dataProvider, persistence } = await initMarketData();
78
- const watchlist = findWatchlist(config, watchlistName);
79
- if (!watchlist) {
80
- closeAndFail(persistence, `Watchlist "${watchlistName}" was not found.`);
81
- }
82
-
83
- try {
84
- const ticker = await resolveTickerForCli(symbol, store, dataProvider);
85
- if (ticker.metadata.watchlists.includes(watchlist.id)) {
86
- console.log(cliStyles.warning(`${ticker.metadata.ticker} is already in "${watchlist.name}".`));
87
- persistence.close();
88
- return;
89
- }
90
-
91
- const nextTicker: TickerRecord = {
92
- ...ticker,
93
- metadata: {
94
- ...ticker.metadata,
95
- watchlists: [...ticker.metadata.watchlists, watchlist.id],
96
- },
97
- };
98
- await store.saveTicker(nextTicker);
99
- console.log(cliStyles.success(`Added ${nextTicker.metadata.ticker} to "${watchlist.name}".`));
100
- if (nextTicker.metadata.name) {
101
- console.log(renderStat("Name", nextTicker.metadata.name));
102
- }
103
- } catch (err: any) {
104
- closeAndFail(persistence, err?.message || `Failed to add ${symbol} to "${watchlist.name}".`);
105
- }
106
-
107
- persistence.close();
108
- }
109
-
110
- async function removeTickerFromWatchlist(watchlistName: string, symbol: string) {
111
- const { config, store, persistence } = await initConfigData();
112
- const watchlist = findWatchlist(config, watchlistName);
113
- if (!watchlist) {
114
- closeAndFail(persistence, `Watchlist "${watchlistName}" was not found.`);
115
- }
116
-
117
- const normalized = symbol.trim().toUpperCase();
118
- const ticker = await store.loadTicker(normalized);
119
- if (!ticker) {
120
- closeAndFail(persistence, `Ticker "${normalized}" was not found in your local data.`);
121
- }
122
- if (!ticker.metadata.watchlists.includes(watchlist.id)) {
123
- closeAndFail(persistence, `${normalized} is not in "${watchlist.name}".`);
124
- }
125
-
126
- const nextTicker: TickerRecord = {
127
- ...ticker,
128
- metadata: {
129
- ...ticker.metadata,
130
- watchlists: ticker.metadata.watchlists.filter((entry) => entry !== watchlist.id),
131
- },
132
- };
133
- await store.saveTicker(nextTicker);
134
- console.log(cliStyles.success(`Removed ${normalized} from "${watchlist.name}".`));
135
- persistence.close();
136
- }
137
-
138
- async function listWatchlists() {
139
- const { config, store, persistence } = await initConfigData();
140
- const tickers = await store.loadAllTickers();
141
-
142
- console.log(renderSection("Watchlists"));
143
- if (config.watchlists.length === 0) {
144
- console.log(cliStyles.muted("No watchlists configured."));
145
- persistence.close();
146
- return;
147
- }
148
-
149
- console.log(renderTable(
150
- [
151
- { header: "Watchlist" },
152
- { header: "ID" },
153
- { header: "Tickers", align: "right" },
154
- ],
155
- config.watchlists.map((watchlist) => [
156
- watchlist.name,
157
- watchlist.id,
158
- String(countCollectionTickers(tickers, "watchlists", watchlist.id)),
159
- ]),
160
- ));
161
- persistence.close();
162
- }
163
-
164
- export async function watchlist(args: string[]) {
165
- const action = args[0];
166
-
167
- if (!action || action === "list") {
168
- await listWatchlists();
169
- return;
170
- }
171
-
172
- if (action === "show") {
173
- const name = args.slice(1).join(" ");
174
- if (!name) fail("Usage: gloomberb watchlist show <name>");
175
- await showCollection(name);
176
- return;
177
- }
178
-
179
- if (action === "create") {
180
- const name = args.slice(1).join(" ");
181
- if (!name) fail("Usage: gloomberb watchlist create <name>");
182
- await createWatchlist(name);
183
- return;
184
- }
185
-
186
- if (action === "delete" || action === "rm") {
187
- const name = args.slice(1).join(" ");
188
- if (!name) fail("Usage: gloomberb watchlist delete <name>");
189
- await deleteWatchlist(name);
190
- return;
191
- }
192
-
193
- if (action === "add") {
194
- const symbol = args.at(-1);
195
- const name = args.slice(1, -1).join(" ");
196
- if (!name || !symbol) fail("Usage: gloomberb watchlist add <watchlist> <ticker>");
197
- await addTickerToWatchlist(name, symbol);
198
- return;
199
- }
200
-
201
- if (action === "remove") {
202
- const symbol = args.at(-1);
203
- const name = args.slice(1, -1).join(" ");
204
- if (!name || !symbol) fail("Usage: gloomberb watchlist remove <watchlist> <ticker>");
205
- await removeTickerFromWatchlist(name, symbol);
206
- return;
207
- }
208
-
209
- await showCollection(args.join(" "));
210
- }
@@ -1,16 +0,0 @@
1
- import type { AppConfig } from "../../types/config";
2
- import type { DataProvider } from "../../types/data-provider";
3
- import { createGloomberbCloudProvider } from "../../sources/gloomberb-cloud";
4
- import { createYahooProvider } from "./yahoo";
5
-
6
- export function createBuiltinDataProviders(config?: Pick<AppConfig, "disabledPlugins">): DataProvider[] {
7
- const disabledPlugins = new Set(config?.disabledPlugins ?? []);
8
- const providers: DataProvider[] = [];
9
-
10
- if (!disabledPlugins.has("gloomberb-cloud")) {
11
- providers.push(createGloomberbCloudProvider());
12
- }
13
-
14
- providers.push(createYahooProvider());
15
- return providers;
16
- }
@@ -1,18 +0,0 @@
1
- import type { DataProvider } from "../types/data-provider";
2
-
3
- /** Convert a value from one currency to the base currency */
4
- export async function convertToBase(
5
- value: number,
6
- fromCurrency: string,
7
- baseCurrency: string,
8
- dataProvider: DataProvider,
9
- ): Promise<number> {
10
- if (fromCurrency === baseCurrency) return value;
11
-
12
- // Convert: fromCurrency -> USD -> baseCurrency
13
- const fromToUsd = await dataProvider.getExchangeRate(fromCurrency);
14
- const baseToUsd = await dataProvider.getExchangeRate(baseCurrency);
15
-
16
- if (baseToUsd === 0) return value;
17
- return (value * fromToUsd) / baseToUsd;
18
- }
@@ -1,172 +0,0 @@
1
- /**
2
- * ASCII stock chart using braille characters (U+2800-U+28FF).
3
- * Each braille char is 2 columns x 4 rows of dots.
4
- */
5
-
6
- const BRAILLE_BASE = 0x2800;
7
- // Dot bit positions for each column (left=0, right=1) and row (0=top, 3=bottom)
8
- const DOT_BITS = [
9
- [0x01, 0x02, 0x04, 0x40], // left column
10
- [0x08, 0x10, 0x20, 0x80], // right column
11
- ];
12
-
13
- export interface StockChartOptions {
14
- width: number; // chart width in characters
15
- height: number; // chart height in terminal rows
16
- showAxis?: boolean;
17
- showLabels?: boolean;
18
- }
19
-
20
- export interface StockChartData {
21
- dates: (Date | string | number)[];
22
- prices: number[];
23
- }
24
-
25
- /**
26
- * Render a full stock chart with braille line, axis labels, and price range.
27
- * Returns an array of strings (one per terminal row).
28
- */
29
- export function renderStockChart(
30
- data: StockChartData,
31
- opts: StockChartOptions,
32
- ): string[] {
33
- const { dates, prices } = data;
34
- if (prices.length === 0) return [];
35
-
36
- const showAxis = opts.showAxis !== false;
37
- const showLabels = opts.showLabels !== false;
38
-
39
- // Reserve space for labels
40
- const priceAxisWidth = showLabels ? 10 : 0;
41
- const chartWidth = opts.width - priceAxisWidth;
42
- const chartHeight = opts.height - (showAxis ? 1 : 0); // bottom row for time axis
43
-
44
- if (chartWidth < 4 || chartHeight < 2) return [];
45
-
46
- const min = Math.min(...prices);
47
- const max = Math.max(...prices);
48
- const range = max - min || 1;
49
- const totalDotsV = chartHeight * 4; // vertical braille resolution
50
-
51
- // Sample prices to fit chart width (2 data points per braille char)
52
- const sampleCount = chartWidth * 2;
53
- const sampled: number[] = [];
54
- const sampledDates: (Date | string | number)[] = [];
55
- for (let i = 0; i < sampleCount; i++) {
56
- const srcIdx = Math.floor((i / sampleCount) * prices.length);
57
- sampled.push(prices[srcIdx]!);
58
- sampledDates.push(dates[srcIdx]!);
59
- }
60
-
61
- // Convert to dot Y positions (0 = bottom, totalDotsV-1 = top)
62
- const dotPositions = sampled.map(
63
- (v) => Math.round(((v - min) / range) * (totalDotsV - 1))
64
- );
65
-
66
- // Build braille chart rows with connected lines
67
- const chartLines: string[] = [];
68
- for (let row = 0; row < chartHeight; row++) {
69
- let line = "";
70
- for (let col = 0; col < chartWidth; col++) {
71
- let bits = 0;
72
- for (let subCol = 0; subCol < 2; subCol++) {
73
- const valueIdx = col * 2 + subCol;
74
- const dotY = dotPositions[valueIdx] ?? 0;
75
- // Get previous data point to interpolate a connected line
76
- const prevIdx = valueIdx - 1;
77
- const prevDotY = prevIdx >= 0 ? (dotPositions[prevIdx] ?? dotY) : dotY;
78
- const lo = Math.min(dotY, prevDotY);
79
- const hi = Math.max(dotY, prevDotY);
80
-
81
- const rowBottomDot = (chartHeight - 1 - row) * 4;
82
- for (let subRow = 0; subRow < 4; subRow++) {
83
- const absoluteDot = rowBottomDot + subRow;
84
- // Draw connected line: fill between current and previous point
85
- if (absoluteDot >= lo && absoluteDot <= hi) {
86
- bits |= DOT_BITS[subCol]![subRow]!;
87
- }
88
- }
89
- }
90
- line += String.fromCharCode(BRAILLE_BASE + bits);
91
- }
92
- chartLines.push(line);
93
- }
94
-
95
- // Build full output with labels
96
- const output: string[] = [];
97
-
98
- for (let row = 0; row < chartHeight; row++) {
99
- let line = "";
100
- if (showLabels) {
101
- // Price label on right side at top, middle, bottom
102
- let label = "";
103
- if (row === 0) {
104
- label = formatAxisPrice(max);
105
- } else if (row === Math.floor(chartHeight / 2)) {
106
- label = formatAxisPrice((max + min) / 2);
107
- } else if (row === chartHeight - 1) {
108
- label = formatAxisPrice(min);
109
- }
110
- line = chartLines[row]! + " " + label.padStart(priceAxisWidth - 1);
111
- } else {
112
- line = chartLines[row]!;
113
- }
114
- output.push(line);
115
- }
116
-
117
- // Time axis
118
- if (showAxis && dates.length > 0) {
119
- const firstDate = dates[0]!;
120
- const lastDate = dates[dates.length - 1]!;
121
- const midDate = dates[Math.floor(dates.length / 2)]!;
122
-
123
- const startLabel = formatAxisDate(firstDate);
124
- const midLabel = formatAxisDate(midDate);
125
- const endLabel = formatAxisDate(lastDate);
126
-
127
- let axis = startLabel;
128
- const midPos = Math.floor(chartWidth / 2) - Math.floor(midLabel.length / 2);
129
- const endPos = chartWidth - endLabel.length;
130
-
131
- // Build axis string with proper spacing
132
- axis = startLabel.padEnd(midPos) + midLabel;
133
- axis = axis.padEnd(endPos) + endLabel;
134
- if (axis.length > chartWidth + priceAxisWidth) {
135
- axis = axis.slice(0, chartWidth + priceAxisWidth);
136
- }
137
- output.push(axis);
138
- }
139
-
140
- return output;
141
- }
142
-
143
- function formatAxisPrice(value: number): string {
144
- if (Math.abs(value) >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
145
- if (Math.abs(value) >= 1e3) return `$${(value / 1e3).toFixed(1)}K`;
146
- return `$${value.toFixed(2)}`;
147
- }
148
-
149
- function formatAxisDate(date: Date | string | number): string {
150
- const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
151
- const d = date instanceof Date ? date : new Date(date);
152
- if (isNaN(d.getTime())) return "—";
153
- return `${months[d.getMonth()]} ${d.getFullYear()}`;
154
- }
155
-
156
- /** Simple block sparkline for inline use (single line) */
157
- export function inlineSparkline(values: number[], width: number): string {
158
- if (values.length === 0) return " ".repeat(width);
159
- const blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
160
- const min = Math.min(...values);
161
- const max = Math.max(...values);
162
- const range = max - min || 1;
163
-
164
- let result = "";
165
- for (let i = 0; i < width; i++) {
166
- const idx = Math.floor((i / width) * values.length);
167
- const normalized = (values[idx]! - min) / range;
168
- const blockIdx = Math.min(Math.floor(normalized * blocks.length), blocks.length - 1);
169
- result += blocks[blockIdx];
170
- }
171
- return result;
172
- }
@@ -1,48 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { collectUniqueTickerSymbols, tokenizeTickerText } from "./ticker-tokenizer";
3
-
4
- describe("tokenizeTickerText", () => {
5
- test("tokenizes plain text around a single ticker", () => {
6
- expect(tokenizeTickerText("Watching $TSLA today")).toEqual([
7
- { kind: "text", value: "Watching " },
8
- { kind: "ticker", value: "$TSLA", symbol: "TSLA" },
9
- { kind: "text", value: " today" },
10
- ]);
11
- });
12
-
13
- test("tokenizes multiple tickers and punctuation boundaries", () => {
14
- expect(tokenizeTickerText("Compare $AAPL, $MSFT. Then $BRK.B?")).toEqual([
15
- { kind: "text", value: "Compare " },
16
- { kind: "ticker", value: "$AAPL", symbol: "AAPL" },
17
- { kind: "text", value: ", " },
18
- { kind: "ticker", value: "$MSFT", symbol: "MSFT" },
19
- { kind: "text", value: ". Then " },
20
- { kind: "ticker", value: "$BRK.B", symbol: "BRK.B" },
21
- { kind: "text", value: "?" },
22
- ]);
23
- });
24
-
25
- test("keeps numeric dollar amounts as plain text", () => {
26
- expect(tokenizeTickerText("Paid $100 for lunch, not $TSLA.")).toEqual([
27
- { kind: "text", value: "Paid $100 for lunch, not " },
28
- { kind: "ticker", value: "$TSLA", symbol: "TSLA" },
29
- { kind: "text", value: "." },
30
- ]);
31
- });
32
-
33
- test("does not match tickers embedded in longer words", () => {
34
- expect(tokenizeTickerText("prefix$TSLA and $AAPLsuffix")).toEqual([
35
- { kind: "text", value: "prefix$TSLA and $AAPLsuffix" },
36
- ]);
37
- });
38
- });
39
-
40
- describe("collectUniqueTickerSymbols", () => {
41
- test("dedupes repeated symbols while preserving first-seen order", () => {
42
- expect(collectUniqueTickerSymbols([
43
- "Watching $TSLA and $AAPL",
44
- "$TSLA still moving",
45
- "Maybe $MSFT too",
46
- ])).toEqual(["TSLA", "AAPL", "MSFT"]);
47
- });
48
- });