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.
Files changed (143) hide show
  1. package/README.md +27 -16
  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-renderer.ts +7 -2
  17. package/src/components/chart/chart-resolution.test.ts +56 -0
  18. package/src/components/chart/chart-resolution.ts +362 -0
  19. package/src/components/chart/chart-types.ts +12 -4
  20. package/src/components/chart/chart-viewport.test.ts +25 -0
  21. package/src/components/chart/chart-viewport.ts +28 -0
  22. package/src/components/chart/chart.test.ts +26 -0
  23. package/src/components/chart/comparison-chart-data.ts +20 -27
  24. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  25. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  26. package/src/components/chart/stock-chart.tsx +484 -112
  27. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  28. package/src/components/command-bar/command-bar.test.tsx +650 -5
  29. package/src/components/command-bar/command-bar.tsx +359 -63
  30. package/src/components/command-bar/command-registry.ts +8 -0
  31. package/src/components/command-bar/helpers.test.ts +23 -1
  32. package/src/components/command-bar/helpers.ts +14 -5
  33. package/src/components/command-bar/pane-template-display.ts +12 -0
  34. package/src/components/command-bar/root-shortcuts.ts +2 -1
  35. package/src/components/command-bar/workflow-ops.ts +4 -2
  36. package/src/components/detail-feed-view.test.tsx +124 -49
  37. package/src/components/detail-feed-view.tsx +396 -160
  38. package/src/components/layout/floating-pane.tsx +1 -1
  39. package/src/components/layout/pane-header.tsx +26 -2
  40. package/src/components/layout/shell.tsx +62 -50
  41. package/src/components/layout/status-bar.test.tsx +66 -0
  42. package/src/components/layout/status-bar.tsx +26 -5
  43. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  44. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  45. package/src/components/ticker-list-table.test.tsx +83 -0
  46. package/src/components/ticker-list-table.tsx +174 -97
  47. package/src/components/ui/data-table.tsx +27 -2
  48. package/src/components/ui/index.ts +1 -0
  49. package/src/components/ui/page-stack-view.tsx +60 -0
  50. package/src/components/use-double-click-activation.ts +31 -0
  51. package/src/index.tsx +10 -10
  52. package/src/market-data/coordinator.test.ts +92 -4
  53. package/src/market-data/coordinator.ts +32 -5
  54. package/src/market-data/hooks.test.tsx +120 -0
  55. package/src/market-data/hooks.tsx +53 -16
  56. package/src/market-data/request-types.ts +4 -2
  57. package/src/market-data/selectors.ts +24 -58
  58. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  59. package/src/plugins/builtin/chat-controller.ts +310 -35
  60. package/src/plugins/builtin/chat.test.tsx +118 -6
  61. package/src/plugins/builtin/chat.tsx +175 -116
  62. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  63. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  64. package/src/plugins/builtin/news.tsx +1 -11
  65. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  66. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  67. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  68. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  69. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  70. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  71. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  72. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  73. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  74. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  75. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  76. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  77. package/src/plugins/builtin/sec.tsx +1 -11
  78. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  79. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  80. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  81. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  82. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  83. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  84. package/src/plugins/catalog.ts +65 -0
  85. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  86. package/src/plugins/ibkr/gateway-service.test.ts +75 -1
  87. package/src/plugins/ibkr/gateway-service.ts +179 -9
  88. package/src/plugins/pane-manager.test.ts +1 -1
  89. package/src/plugins/plugin-runtime.tsx +27 -7
  90. package/src/plugins/prediction-markets/chart.test.tsx +139 -0
  91. package/src/plugins/prediction-markets/chart.tsx +65 -436
  92. package/src/plugins/prediction-markets/cli.ts +28 -0
  93. package/src/plugins/prediction-markets/controller-data.ts +17 -5
  94. package/src/plugins/prediction-markets/controller.ts +141 -121
  95. package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
  96. package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
  97. package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
  98. package/src/plugins/prediction-markets/detail.test.tsx +4 -0
  99. package/src/plugins/prediction-markets/index.tsx +2 -0
  100. package/src/plugins/prediction-markets/launch.ts +51 -16
  101. package/src/plugins/prediction-markets/metrics.ts +9 -5
  102. package/src/plugins/prediction-markets/pane.test.tsx +160 -58
  103. package/src/plugins/prediction-markets/pane.tsx +74 -183
  104. package/src/plugins/prediction-markets/table.tsx +6 -3
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/sources/gloomberb-cloud.test.ts +29 -0
  107. package/src/sources/gloomberb-cloud.ts +89 -1
  108. package/src/sources/provider-router.test.ts +308 -3
  109. package/src/sources/provider-router.ts +247 -17
  110. package/src/sources/yahoo-finance.test.ts +26 -0
  111. package/src/sources/yahoo-finance.ts +54 -3
  112. package/src/state/app-context-selector.test.tsx +99 -0
  113. package/src/state/app-context.test.ts +18 -0
  114. package/src/state/app-context.tsx +170 -65
  115. package/src/state/selectors.ts +25 -0
  116. package/src/theme/colors.ts +8 -8
  117. package/src/theme/themes.test.ts +2 -0
  118. package/src/theme/themes.ts +22 -10
  119. package/src/types/broker.ts +21 -0
  120. package/src/types/config.ts +17 -1
  121. package/src/types/data-provider.ts +18 -0
  122. package/src/types/financials.ts +31 -1
  123. package/src/types/plugin.ts +70 -0
  124. package/src/utils/api-client.test.ts +57 -0
  125. package/src/utils/api-client.ts +35 -12
  126. package/src/utils/back-navigation.ts +27 -0
  127. package/src/utils/format.test.ts +14 -0
  128. package/src/utils/format.ts +3 -3
  129. package/src/utils/market-status.ts +46 -1
  130. package/src/utils/price-history.test.ts +25 -0
  131. package/src/utils/price-history.ts +29 -5
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
package/README.md CHANGED
@@ -25,22 +25,18 @@ For the best experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compati
25
25
  ## 🚀 Install
26
26
 
27
27
  ```bash
28
- bun install -g gloomberb
29
- # or
30
28
  curl -fsSL gloomberb.com/install | bash
29
+ # or
30
+ bun install -g gloomberb
31
31
  ```
32
32
 
33
- Then run `gloomberb` to start.
34
-
35
- When developing from source, launch with a real Bun runtime, for example `bun src/index.tsx`.
36
- If `bun --help` prints Gloomberb help instead of Bun help, your Bun binary has been overwritten and needs to be reinstalled.
37
- In-app self-updates are disabled while running from source so Gloomberb does not replace the Bun executable.
33
+ Then run `gloomberb`.
38
34
 
39
35
  ## 🧩 Plugins
40
36
 
41
- Gloomberb has a plugin architecture where everything, from the portfolio list to broker integrations, is a plugin.
37
+ Everything, from the portfolio list to broker integrations, is a plugin.
42
38
 
43
- Plugins can add tabs, columns, commands, status bar widgets, and more.
39
+ Plugins can add tabs, columns, command bar commands, CLI commands, status bar widgets, and more.
44
40
 
45
41
  See **[PLUGINS.md](PLUGINS.md)** for the plugin API and the shared UI surface available through `gloomberb/components`.
46
42
 
@@ -60,12 +56,6 @@ See **[PLUGINS.md](PLUGINS.md)** for the plugin API and the shared UI surface av
60
56
  | **Alerts** | WIP |
61
57
  | **Scanner** | WIP |
62
58
 
63
- #### AI plugin
64
-
65
- - The ticker detail pane keeps its `Ask AI` tab for per-company questions.
66
- - The `AI Screener` pane lets you create multiple prompt-based screening tabs, refresh them, force reruns, edit the prompt, and review the last run time.
67
- - Use the `AI <prompt>` shortcut from the command bar to open a new screener pane seeded with a prompt.
68
-
69
59
  ### Data providers
70
60
 
71
61
  | Provider | Description |
@@ -82,6 +72,23 @@ See **[PLUGINS.md](PLUGINS.md)** for the plugin API and the shared UI surface av
82
72
 
83
73
  Toggleable plugins can be enabled/disabled from the command bar screen (`Ctrl+p`).
84
74
 
75
+ ## 💻 CLI
76
+
77
+ Running `gloomberb` with no arguments launches the terminal UI. Use `gloomberb help` to see the full command list.
78
+ Feature-owned root commands such as `portfolio`, `watchlist`, and `predictions` are registered by their plugins rather than hardcoded in the main CLI switch.
79
+
80
+ ```bash
81
+ gloomberb
82
+ gloomberb help
83
+ gloomberb search NVDA
84
+ gloomberb ticker AAPL
85
+ gloomberb portfolio create Research
86
+ gloomberb portfolio position set Research NVDA 10 400
87
+ gloomberb watchlist list
88
+ gloomberb plugins
89
+ gloomberb predictions world
90
+ ```
91
+
85
92
  ## ⌨️ Keyboard Shortcuts
86
93
 
87
94
  | Key | Action |
@@ -89,7 +96,7 @@ Toggleable plugins can be enabled/disabled from the command bar screen (`Ctrl+p`
89
96
  | `Ctrl+P` or `` ` `` | Open command bar |
90
97
  | `Ctrl+x` | Close current window |
91
98
  | `Tab` | Switch between panels |
92
- `j` / `k` | Navigate ticker list |
99
+ | `j` / `k` | Navigate ticker list |
93
100
  | `h` / `l` | Switch tabs |
94
101
  | `Ctrl+,` | Open settings |
95
102
  | `m` | Cycle chart mode in the chart tab |
@@ -98,3 +105,7 @@ Toggleable plugins can be enabled/disabled from the command bar screen (`Ctrl+p`
98
105
  ## License
99
106
 
100
107
  MIT
108
+
109
+ ## Credits
110
+
111
+ - [OpenTUI](https://opentui.com/) for the layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useRef, useMemo } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react";
2
2
  import { useKeyboard, useRenderer } from "@opentui/react";
3
3
  import type { CliRenderer } from "@opentui/core";
4
4
  import {
@@ -32,7 +32,7 @@ import {
32
32
  type PaneBinding,
33
33
  type PaneInstanceConfig,
34
34
  } from "./types/config";
35
- import type { PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
35
+ import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
36
36
  import type { PaneSettingField } from "./types/plugin";
37
37
  import type { TickerRecord } from "./types/ticker";
38
38
  import type { DataProvider } from "./types/data-provider";
@@ -41,33 +41,14 @@ import type { BrokerAccount } from "./types/trading";
41
41
  import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
42
42
 
43
43
  // Built-in plugins
44
- import { portfolioListPlugin } from "./plugins/builtin/portfolio-list";
45
- import { tickerDetailPlugin } from "./plugins/builtin/ticker-detail";
46
- import { manualEntryPlugin } from "./plugins/builtin/manual-entry";
47
- import { ibkrPlugin } from "./plugins/ibkr";
48
44
  import {
49
45
  clearPersistedIbkrAccounts,
50
46
  loadPersistedIbkrAccountMap,
51
47
  } from "./plugins/ibkr/account-cache";
52
48
  import { getIbkrConfigIdentity } from "./plugins/ibkr/config";
53
- import { newsPlugin } from "./plugins/builtin/news";
54
- import { secPlugin } from "./plugins/builtin/sec";
55
- import { optionsPlugin } from "./plugins/builtin/options";
56
- import { notesPlugin } from "./plugins/builtin/notes";
57
- import { aiPlugin } from "./plugins/builtin/ai/index";
58
- import { gloomberbCloudPlugin } from "./plugins/builtin/chat";
59
49
  import { chatController } from "./plugins/builtin/chat-controller";
60
- import { helpPlugin } from "./plugins/builtin/help";
61
- import { comparisonChartPlugin } from "./plugins/builtin/comparison-chart";
62
- import { debugPlugin } from "./plugins/builtin/debug";
63
- import { layoutManagerPlugin, setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
64
- import { yahooPlugin } from "./plugins/builtin/yahoo";
65
- import { predictionMarketsPlugin } from "./plugins/prediction-markets";
66
- import {
67
- applyPredictionLaunchIntentToConfig,
68
- applyPredictionLaunchIntentToSessionSnapshot,
69
- type PredictionLaunchIntent,
70
- } from "./plugins/prediction-markets/launch";
50
+ import { setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
51
+ import { getLoadablePlugins } from "./plugins/catalog";
71
52
  import { saveConfig } from "./data/config-store";
72
53
  import { Toaster, toast } from "@opentui-ui/toast/react";
73
54
  import { canSelfUpdate, checkForUpdateDetailed, performUpdate } from "./updater";
@@ -107,6 +88,7 @@ import {
107
88
  applyPaneSettingFieldValue as applyPaneSettingFieldValueShared,
108
89
  createPaneTemplateOrThrow,
109
90
  } from "./components/command-bar/workflow-ops";
91
+ import { getPaneTemplateDisplayLabel } from "./components/command-bar/pane-template-display";
110
92
  import { debugLog } from "./utils/debug-log";
111
93
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./market-data/coordinator";
112
94
  import { instrumentFromTicker } from "./market-data/request-types";
@@ -966,7 +948,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
966
948
  });
967
949
  } catch (error) {
968
950
  pluginRegistry.showToastFn(
969
- error instanceof Error ? error.message : `Could not create ${template.label.toLowerCase()}.`,
951
+ error instanceof Error ? error.message : `Could not create ${getPaneTemplateDisplayLabel(template).toLowerCase()}.`,
970
952
  { type: "info" },
971
953
  );
972
954
  }
@@ -1232,32 +1214,36 @@ interface AppProps {
1232
1214
  config: AppConfig;
1233
1215
  renderer: CliRenderer;
1234
1216
  externalPlugins?: import("./plugins/loader").LoadedExternalPlugin[];
1235
- predictionLaunchIntent?: PredictionLaunchIntent | null;
1217
+ cliLaunchRequest?: CliLaunchRequest | null;
1236
1218
  }
1237
1219
 
1238
1220
  export function App({
1239
1221
  config: initialConfig,
1240
1222
  renderer,
1241
1223
  externalPlugins = [],
1242
- predictionLaunchIntent = null,
1224
+ cliLaunchRequest = null,
1243
1225
  }: AppProps) {
1244
- const applyPredictionLaunch = useCallback((configValue: AppConfig) => {
1245
- if (!predictionLaunchIntent) return configValue;
1246
- return applyPredictionLaunchIntentToConfig(configValue, predictionLaunchIntent, {
1247
- width: Math.max(renderer.terminalWidth, 120),
1248
- height: Math.max(renderer.terminalHeight, 40),
1249
- }).config;
1250
- }, [predictionLaunchIntent, renderer.terminalHeight, renderer.terminalWidth]);
1226
+ const initialCliLaunch = useMemo(() => {
1227
+ if (!cliLaunchRequest) {
1228
+ return { config: initialConfig, launchState: undefined };
1229
+ }
1230
+ return cliLaunchRequest.applyConfig(initialConfig, {
1231
+ terminalWidth: renderer.terminalWidth,
1232
+ terminalHeight: renderer.terminalHeight,
1233
+ });
1234
+ }, [cliLaunchRequest, initialConfig, renderer.terminalHeight, renderer.terminalWidth]);
1235
+ const cliLaunchStateRef = useRef(initialCliLaunch.launchState);
1251
1236
 
1252
1237
  const [config, setConfig] = useState(() => {
1253
- return applyPredictionLaunch(initialConfig);
1238
+ return initialCliLaunch.config;
1254
1239
  });
1255
1240
  const [showOnboarding, setShowOnboarding] = useState(!initialConfig.onboardingComplete);
1256
1241
 
1257
1242
  useEffect(() => bindAppActivity(renderer), [renderer]);
1258
1243
 
1259
- // Apply saved theme before first render
1260
- if (config.theme) applyTheme(config.theme);
1244
+ useLayoutEffect(() => {
1245
+ if (config.theme) applyTheme(config.theme);
1246
+ }, [config.theme]);
1261
1247
 
1262
1248
  const services = useMemo(() => {
1263
1249
  const dbPath = join(config.dataDir, ".gloomberb-cache.db");
@@ -1271,25 +1257,8 @@ export function App({
1271
1257
  pluginRegistry.getConfigFn = () => config;
1272
1258
  pluginRegistry.getLayoutFn = () => config.layout;
1273
1259
 
1274
- pluginRegistry.register(yahooPlugin);
1275
- pluginRegistry.register(gloomberbCloudPlugin);
1276
- pluginRegistry.register(portfolioListPlugin);
1277
- pluginRegistry.register(tickerDetailPlugin);
1278
- pluginRegistry.register(manualEntryPlugin);
1279
- pluginRegistry.register(ibkrPlugin);
1280
- pluginRegistry.register(layoutManagerPlugin);
1281
- pluginRegistry.register(newsPlugin);
1282
- pluginRegistry.register(secPlugin);
1283
- pluginRegistry.register(optionsPlugin);
1284
- pluginRegistry.register(notesPlugin);
1285
- pluginRegistry.register(aiPlugin);
1286
- pluginRegistry.register(helpPlugin);
1287
- pluginRegistry.register(comparisonChartPlugin);
1288
- pluginRegistry.register(predictionMarketsPlugin);
1289
- pluginRegistry.register(debugPlugin);
1290
-
1291
- for (const { plugin, error } of externalPlugins) {
1292
- if (!error) pluginRegistry.register(plugin);
1260
+ for (const plugin of getLoadablePlugins(externalPlugins)) {
1261
+ pluginRegistry.register(plugin);
1293
1262
  }
1294
1263
 
1295
1264
  return {
@@ -1316,20 +1285,15 @@ export function App({
1316
1285
  const sessionSnapshot = useMemo(() => {
1317
1286
  const persisted = services.persistence.sessions.get<AppSessionSnapshot>(APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION)?.value ?? null;
1318
1287
  const reconciled = reconcileAppSessionSnapshot(config, persisted);
1319
- if (!predictionLaunchIntent) {
1320
- return reconciled;
1321
- }
1322
- const paneInstanceId = config.layout.instances.find((instance) => instance.paneId === "prediction-markets")?.instanceId;
1323
- if (!paneInstanceId) {
1288
+ if (!cliLaunchRequest?.applySessionSnapshot) {
1324
1289
  return reconciled;
1325
1290
  }
1326
- return applyPredictionLaunchIntentToSessionSnapshot(
1291
+ return cliLaunchRequest.applySessionSnapshot(
1327
1292
  config,
1328
1293
  reconciled,
1329
- paneInstanceId,
1330
- predictionLaunchIntent,
1294
+ cliLaunchStateRef.current,
1331
1295
  );
1332
- }, [config, predictionLaunchIntent, services.persistence.sessions]);
1296
+ }, [cliLaunchRequest, config, services.persistence.sessions]);
1333
1297
 
1334
1298
  if (showOnboarding) {
1335
1299
  return (
@@ -1337,7 +1301,7 @@ export function App({
1337
1301
  config={config}
1338
1302
  pluginRegistry={services.pluginRegistry}
1339
1303
  onComplete={(updatedConfig) => {
1340
- setConfig(applyPredictionLaunch(updatedConfig));
1304
+ setConfig(updatedConfig);
1341
1305
  setShowOnboarding(false);
1342
1306
  }}
1343
1307
  />
@@ -0,0 +1,31 @@
1
+ import type { DataProvider } from "../types/data-provider";
2
+
3
+ export function createBaseConverter(dataProvider: Pick<DataProvider, "getExchangeRate">, baseCurrency: string) {
4
+ const rateCache = new Map<string, number>([["USD", 1]]);
5
+
6
+ const getRate = async (currency: string): Promise<number> => {
7
+ const normalizedCurrency = currency.toUpperCase();
8
+ const cached = rateCache.get(normalizedCurrency);
9
+ if (cached != null) return cached;
10
+ try {
11
+ const rate = await dataProvider.getExchangeRate(normalizedCurrency);
12
+ rateCache.set(normalizedCurrency, rate);
13
+ return rate;
14
+ } catch {
15
+ return 1;
16
+ }
17
+ };
18
+
19
+ return async (value: number, fromCurrency: string): Promise<number> => {
20
+ const normalizedFrom = fromCurrency.toUpperCase();
21
+ const normalizedBase = baseCurrency.toUpperCase();
22
+ if (normalizedFrom === normalizedBase) return value;
23
+
24
+ const [fromRate, baseRate] = await Promise.all([
25
+ getRate(normalizedFrom),
26
+ getRate(normalizedBase),
27
+ ]);
28
+ if (baseRate === 0) return value;
29
+ return (value * fromRate) / baseRate;
30
+ };
31
+ }
@@ -11,6 +11,12 @@ import {
11
11
  } from "../../utils/ticker-search";
12
12
  import { initMarketData } from "../context";
13
13
  import { fail } from "../errors";
14
+ import type { MarketContext } from "../types";
15
+
16
+ interface SearchCommandDependencies {
17
+ initMarketData?: () => Promise<MarketContext>;
18
+ fail?: (message: string, details?: string) => never;
19
+ }
14
20
 
15
21
  export async function searchCandidatesForCli({
16
22
  query,
@@ -97,13 +103,15 @@ export function buildSearchReport({
97
103
  return lines.join("\n");
98
104
  }
99
105
 
100
- export async function search(query: string) {
106
+ export async function search(query: string, dependencies: SearchCommandDependencies = {}) {
101
107
  const trimmedQuery = query.trim();
108
+ const failCommand = dependencies.fail ?? fail;
109
+ const initMarketDataFn = dependencies.initMarketData ?? initMarketData;
102
110
  if (!trimmedQuery) {
103
- fail("Usage: gloomberb search <query>");
111
+ failCommand("Usage: gloomberb search <query>");
104
112
  }
105
113
 
106
- const { store, dataProvider, persistence } = await initMarketData();
114
+ const { store, dataProvider, persistence } = await initMarketDataFn();
107
115
  const tickers = await store.loadAllTickers();
108
116
  const candidates = await searchCandidatesForCli({
109
117
  query: trimmedQuery,
@@ -15,8 +15,10 @@ import type { AppConfig } from "../../types/config";
15
15
  import type { FinancialStatement, TickerFinancials } from "../../types/financials";
16
16
  import type { NewsItem, SecFilingItem } from "../../types/data-provider";
17
17
  import type { TickerRecord } from "../../types/ticker";
18
- import { createBaseConverter, initMarketData } from "../context";
18
+ import { createBaseConverter } from "../base-converter";
19
+ import { initMarketData } from "../context";
19
20
  import { closeAndFail } from "../errors";
21
+ import type { MarketContext } from "../types";
20
22
  import {
21
23
  formatBidAsk,
22
24
  formatNullableCompact,
@@ -33,6 +35,11 @@ import { isUsEquityTicker } from "../../utils/sec";
33
35
  const NEWS_ITEM_LIMIT = 5;
34
36
  const SEC_FILING_LIMIT = 5;
35
37
 
38
+ interface TickerCommandDependencies {
39
+ initMarketData?: () => Promise<MarketContext>;
40
+ closeAndFail?: typeof closeAndFail;
41
+ }
42
+
36
43
  function appendMetricSection(lines: string[], title: string, metrics: Array<[string, string]>) {
37
44
  const populated = metrics.filter(([, value]) => value !== "—");
38
45
  if (populated.length === 0) return;
@@ -329,30 +336,34 @@ export async function buildTickerReport({
329
336
  return lines.join("\n");
330
337
  }
331
338
 
332
- export async function ticker(symbol: string) {
333
- const { config, store, dataProvider, persistence, dataDir } = await initMarketData();
339
+ export async function ticker(symbol: string, dependencies: TickerCommandDependencies = {}) {
340
+ const initMarketDataFn = dependencies.initMarketData ?? initMarketData;
341
+ const closeAndFailCommand = dependencies.closeAndFail ?? closeAndFail;
342
+ const { config, store, dataProvider, persistence, dataDir } = await initMarketDataFn();
334
343
  const normalized = symbol.trim().toUpperCase();
335
344
  const tickerFile = await store.loadTicker(normalized);
336
345
  const exchange = tickerFile?.metadata.exchange ?? "";
337
346
  const toBase = createBaseConverter(dataProvider, config.baseCurrency);
338
347
 
339
- let financials: TickerFinancials;
348
+ let financials: TickerFinancials | null = null;
340
349
  try {
341
350
  financials = await dataProvider.getTickerFinancials(normalized, exchange);
342
351
  } catch (err: any) {
343
- closeAndFail(persistence, `Failed to fetch data for ${normalized}.`, err?.message);
352
+ closeAndFailCommand(persistence, `Failed to fetch data for ${normalized}.`, err?.message);
344
353
  }
345
354
 
346
- if (!financials.quote) {
347
- closeAndFail(persistence, `No quote data available for ${normalized}.`);
355
+ if (!financials?.quote) {
356
+ closeAndFailCommand(persistence, `No quote data available for ${normalized}.`);
348
357
  }
358
+ const resolvedFinancials = financials as TickerFinancials;
359
+ const quote = resolvedFinancials.quote!;
349
360
 
350
361
  const notesFiles = new NotesFiles(dataDir);
351
362
  const [notesResult, newsResult, secFilingsResult] = await Promise.allSettled([
352
363
  notesFiles.load(normalized),
353
- dataProvider.getNews(normalized, NEWS_ITEM_LIMIT, exchange || financials.quote.exchangeName || ""),
354
- shouldFetchSecFilings(tickerFile, financials) && dataProvider.getSecFilings
355
- ? dataProvider.getSecFilings(normalized, SEC_FILING_LIMIT, exchange || financials.quote.exchangeName || "")
364
+ dataProvider.getNews(normalized, NEWS_ITEM_LIMIT, exchange || quote.exchangeName || ""),
365
+ shouldFetchSecFilings(tickerFile, resolvedFinancials) && dataProvider.getSecFilings
366
+ ? dataProvider.getSecFilings(normalized, SEC_FILING_LIMIT, exchange || quote.exchangeName || "")
356
367
  : Promise.resolve([]),
357
368
  ]);
358
369
 
@@ -363,7 +374,7 @@ export async function ticker(symbol: string) {
363
374
  console.log(await buildTickerReport({
364
375
  symbol: normalized,
365
376
  tickerFile,
366
- financials,
377
+ financials: resolvedFinancials,
367
378
  config,
368
379
  toBase,
369
380
  notes,
@@ -5,10 +5,32 @@ import { AppPersistence } from "../data/app-persistence";
5
5
  import { TickerRepository } from "../data/ticker-repository";
6
6
  import { ProviderRouter } from "../sources/provider-router";
7
7
  import type { DataProvider } from "../types/data-provider";
8
- import { createBuiltinDataProviders } from "../plugins/builtin/data-providers";
8
+ import type { AppConfig } from "../types/config";
9
+ import type { GloomPlugin } from "../types/plugin";
10
+ import { getLoadablePlugins } from "../plugins/catalog";
11
+ import { createBaseConverter } from "./base-converter";
9
12
  import { fail } from "./errors";
10
13
  import type { ConfigContext, MarketContext } from "./types";
11
14
 
15
+ interface CliContextOptions {
16
+ plugins?: GloomPlugin[];
17
+ }
18
+
19
+ function resolveCliDataProviders(config: AppConfig, plugins: GloomPlugin[]): DataProvider[] {
20
+ const disabledPlugins = new Set(config.disabledPlugins ?? []);
21
+ return plugins
22
+ .filter((plugin) => !disabledPlugins.has(plugin.id) && !!plugin.dataProvider)
23
+ .map((plugin) => plugin.dataProvider as DataProvider);
24
+ }
25
+
26
+ export async function loadCliConfigIfAvailable(): Promise<AppConfig | null> {
27
+ const dataDir = await getDataDir();
28
+ if (!dataDir || !existsSync(dataDir)) {
29
+ return null;
30
+ }
31
+ return loadConfig(dataDir);
32
+ }
33
+
12
34
  export async function initConfigData(): Promise<ConfigContext> {
13
35
  const dataDir = await getDataDir();
14
36
  if (!dataDir || !existsSync(dataDir)) {
@@ -21,42 +43,15 @@ export async function initConfigData(): Promise<ConfigContext> {
21
43
  return { config, persistence, store, dataDir };
22
44
  }
23
45
 
24
- export async function initMarketData(): Promise<MarketContext> {
46
+ export async function initMarketData(options: CliContextOptions = {}): Promise<MarketContext> {
25
47
  const context = await initConfigData();
48
+ const plugins = options.plugins ?? getLoadablePlugins();
26
49
  const dataProvider = new ProviderRouter(
27
50
  null,
28
- createBuiltinDataProviders(context.config),
51
+ resolveCliDataProviders(context.config, plugins),
29
52
  context.persistence.resources,
30
53
  );
31
54
  return { ...context, dataProvider };
32
55
  }
33
56
 
34
- export function createBaseConverter(dataProvider: Pick<DataProvider, "getExchangeRate">, baseCurrency: string) {
35
- const rateCache = new Map<string, number>([["USD", 1]]);
36
-
37
- const getRate = async (currency: string): Promise<number> => {
38
- const normalizedCurrency = currency.toUpperCase();
39
- const cached = rateCache.get(normalizedCurrency);
40
- if (cached != null) return cached;
41
- try {
42
- const rate = await dataProvider.getExchangeRate(normalizedCurrency);
43
- rateCache.set(normalizedCurrency, rate);
44
- return rate;
45
- } catch {
46
- return 1;
47
- }
48
- };
49
-
50
- return async (value: number, fromCurrency: string): Promise<number> => {
51
- const normalizedFrom = fromCurrency.toUpperCase();
52
- const normalizedBase = baseCurrency.toUpperCase();
53
- if (normalizedFrom === normalizedBase) return value;
54
-
55
- const [fromRate, baseRate] = await Promise.all([
56
- getRate(normalizedFrom),
57
- getRate(normalizedBase),
58
- ]);
59
- if (baseRate === 0) return value;
60
- return (value * fromRate) / baseRate;
61
- };
62
- }
57
+ export { createBaseConverter };
@@ -4,13 +4,10 @@ import {
4
4
  formatNumber,
5
5
  formatPercentRaw,
6
6
  } from "../utils/format";
7
+ export { slugifyName } from "../utils/slugify";
7
8
  import type { AppConfig } from "../types/config";
8
9
  import type { Watchlist, TickerRecord } from "../types/ticker";
9
10
 
10
- export function slugifyName(name: string, fallbackPrefix: string): string {
11
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || `${fallbackPrefix}-${Date.now()}`;
12
- }
13
-
14
11
  export function formatSignedCurrency(value: number, currency: string): string {
15
12
  return value > 0 ? `+${formatCurrency(value, currency)}` : formatCurrency(value, currency);
16
13
  }