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
@@ -0,0 +1,122 @@
1
+ import type { CommandBarFieldValue, CommandBarWorkflowField } from "../../../components/command-bar/workflow-types";
2
+ import type { AppConfig } from "../../../types/config";
3
+ import type { TickerRecord } from "../../../types/ticker";
4
+ import { getManualPortfolioPosition, isManualPortfolio } from "./mutations";
5
+
6
+ export interface SetPortfolioPositionWorkflowState {
7
+ fields: CommandBarWorkflowField[];
8
+ values: Record<string, CommandBarFieldValue>;
9
+ pendingLabel: string;
10
+ }
11
+
12
+ interface ManualPortfolioPositionWorkflowOptions {
13
+ preferredPortfolioId?: string | null;
14
+ ticker: TickerRecord | null;
15
+ pendingLabel: string;
16
+ positionOptional?: boolean;
17
+ defaultAvgCost?: number | null;
18
+ }
19
+
20
+ function buildManualPortfolioPositionWorkflow(
21
+ config: AppConfig,
22
+ options: ManualPortfolioPositionWorkflowOptions,
23
+ ): SetPortfolioPositionWorkflowState | null {
24
+ const manualPortfolios = config.portfolios.filter(isManualPortfolio);
25
+ if (manualPortfolios.length === 0) return null;
26
+
27
+ const preferredPortfolio = manualPortfolios.find((portfolio) => portfolio.id === options.preferredPortfolioId) ?? manualPortfolios[0]!;
28
+ const preferredPosition = options.ticker
29
+ ? getManualPortfolioPosition(options.ticker, preferredPortfolio.id)
30
+ : null;
31
+
32
+ const fields: CommandBarWorkflowField[] = [
33
+ {
34
+ id: "portfolioId",
35
+ label: "Portfolio",
36
+ type: "select",
37
+ options: manualPortfolios.map((portfolio) => ({
38
+ label: portfolio.name,
39
+ value: portfolio.id,
40
+ description: portfolio.currency,
41
+ })),
42
+ required: true,
43
+ },
44
+ {
45
+ id: "ticker",
46
+ label: "Ticker",
47
+ type: "text",
48
+ placeholder: "AAPL",
49
+ required: true,
50
+ },
51
+ {
52
+ id: "shares",
53
+ label: "Shares",
54
+ type: "number",
55
+ placeholder: "10",
56
+ required: !options.positionOptional,
57
+ },
58
+ {
59
+ id: "avgCost",
60
+ label: "Avg Cost",
61
+ type: "number",
62
+ placeholder: "180",
63
+ required: !options.positionOptional,
64
+ },
65
+ {
66
+ id: "currency",
67
+ label: "Currency",
68
+ type: "text",
69
+ placeholder: preferredPortfolio.currency,
70
+ required: false,
71
+ },
72
+ ];
73
+
74
+ const values: Record<string, CommandBarFieldValue> = {
75
+ portfolioId: preferredPortfolio.id,
76
+ ticker: options.ticker?.metadata.ticker ?? "",
77
+ shares: preferredPosition ? String(preferredPosition.shares) : "",
78
+ avgCost: preferredPosition
79
+ ? String(preferredPosition.avgCost)
80
+ : Number.isFinite(options.defaultAvgCost)
81
+ ? String(options.defaultAvgCost)
82
+ : "",
83
+ currency: preferredPosition?.currency ?? "",
84
+ };
85
+
86
+ return {
87
+ fields,
88
+ values,
89
+ pendingLabel: options.pendingLabel,
90
+ };
91
+ }
92
+
93
+ export function buildSetPortfolioPositionWorkflow(
94
+ config: AppConfig,
95
+ options: {
96
+ activeCollectionId: string | null;
97
+ activeTicker: TickerRecord | null;
98
+ },
99
+ ): SetPortfolioPositionWorkflowState | null {
100
+ return buildManualPortfolioPositionWorkflow(config, {
101
+ preferredPortfolioId: options.activeCollectionId,
102
+ ticker: options.activeTicker,
103
+ pendingLabel: "Saving position…",
104
+ });
105
+ }
106
+
107
+ export function buildAddToPortfolioWorkflow(
108
+ config: AppConfig,
109
+ options: {
110
+ preferredPortfolioId?: string | null;
111
+ ticker: TickerRecord | null;
112
+ defaultAvgCost?: number | null;
113
+ },
114
+ ): SetPortfolioPositionWorkflowState | null {
115
+ return buildManualPortfolioPositionWorkflow(config, {
116
+ preferredPortfolioId: options.preferredPortfolioId,
117
+ ticker: options.ticker,
118
+ pendingLabel: "Adding to portfolio…",
119
+ positionOptional: true,
120
+ defaultAvgCost: options.defaultAvgCost,
121
+ });
122
+ }
@@ -1,13 +1,11 @@
1
- import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { TextAttributes } from "@opentui/core";
3
3
  import type { AppState } from "../../../state/app-context";
4
- import { useFxRatesMap } from "../../../market-data/hooks";
5
4
  import { colors, priceColor } from "../../../theme/colors";
6
5
  import type { TickerFinancials } from "../../../types/financials";
7
6
  import type { Portfolio, TickerRecord } from "../../../types/ticker";
8
7
  import { formatCompact, formatNumber, formatPercentRaw, padTo } from "../../../utils/format";
9
8
  import { getMostRecentQuoteUpdate } from "../../../utils/quote-time";
10
- import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
11
9
  import { ibkrGatewayManager } from "../../ibkr/gateway-service";
12
10
  import { calculatePortfolioSummaryTotals } from "./metrics";
13
11
  import {
@@ -18,22 +16,6 @@ import {
18
16
  type ResolvedPortfolioAccountState,
19
17
  } from "./summary";
20
18
 
21
- function getSummaryCurrencies(
22
- tickers: TickerRecord[],
23
- financialsMap: Map<string, TickerFinancials>,
24
- baseCurrency: string,
25
- accountState: ResolvedPortfolioAccountState | null,
26
- ): string[] {
27
- return [
28
- baseCurrency,
29
- ...tickers.map((ticker) => ticker.metadata.currency),
30
- ...tickers.map((ticker) => financialsMap.get(ticker.metadata.ticker)?.quote?.currency),
31
- ...tickers.flatMap((ticker) => ticker.metadata.positions.map((position) => position.currency)),
32
- ...(accountState?.visibleCashBalances.map((balance) => balance.currency) ?? []),
33
- ...(accountState?.visibleCashBalances.map((balance) => balance.baseCurrency) ?? []),
34
- ];
35
- }
36
-
37
19
  export function shouldToggleCashMarginDrawer(key: string | undefined, showCashDrawer: boolean): boolean {
38
20
  return key === "c" && showCashDrawer;
39
21
  }
@@ -49,7 +31,7 @@ export function usePortfolioAccountState(
49
31
  );
50
32
  return useMemo(
51
33
  () => resolvePortfolioAccountState(portfolio, state, snapshot),
52
- [portfolio, snapshot, state],
34
+ [portfolio, snapshot, state.brokerAccounts, state.config],
53
35
  );
54
36
  }
55
37
 
@@ -139,10 +121,12 @@ export function PortfolioCashMarginDrawer({
139
121
  );
140
122
  }
141
123
 
142
- export function PortfolioSummaryBar({
124
+ export const PortfolioSummaryBar = memo(function PortfolioSummaryBar({
143
125
  tickers,
144
126
  financialsMap,
145
- state,
127
+ baseCurrency,
128
+ exchangeRates,
129
+ refreshingCount,
146
130
  isPortfolio,
147
131
  collectionId,
148
132
  width,
@@ -150,7 +134,9 @@ export function PortfolioSummaryBar({
150
134
  }: {
151
135
  tickers: TickerRecord[];
152
136
  financialsMap: Map<string, TickerFinancials>;
153
- state: AppState;
137
+ baseCurrency: string;
138
+ exchangeRates: Map<string, number>;
139
+ refreshingCount: number;
154
140
  isPortfolio: boolean;
155
141
  collectionId: string | null;
156
142
  width: number;
@@ -163,7 +149,7 @@ export function PortfolioSummaryBar({
163
149
  const wasRefreshing = useRef(false);
164
150
 
165
151
  useEffect(() => {
166
- if (state.refreshing.size > 0) {
152
+ if (refreshingCount > 0) {
167
153
  wasRefreshing.current = true;
168
154
  return;
169
155
  }
@@ -171,7 +157,7 @@ export function PortfolioSummaryBar({
171
157
  wasRefreshing.current = false;
172
158
  setLastRefresh(new Date());
173
159
  }
174
- }, [state.refreshing.size]);
160
+ }, [refreshingCount]);
175
161
 
176
162
  useEffect(() => {
177
163
  if (financialsMap.size > 0 && !lastRefresh) {
@@ -179,27 +165,23 @@ export function PortfolioSummaryBar({
179
165
  }
180
166
  }, [financialsMap.size, lastRefresh]);
181
167
 
182
- const exchangeRates = useFxRatesMap(
183
- getSummaryCurrencies(tickers, financialsMap, state.config.baseCurrency, accountState),
184
- );
185
- const effectiveExchangeRates = selectEffectiveExchangeRates(exchangeRates, state.exchangeRates);
186
168
  const totals = useMemo(
187
169
  () => calculatePortfolioSummaryTotals(
188
170
  tickers,
189
171
  financialsMap,
190
- state.config.baseCurrency,
191
- effectiveExchangeRates,
172
+ baseCurrency,
173
+ exchangeRates,
192
174
  isPortfolio,
193
175
  collectionId,
194
176
  ),
195
- [tickers, financialsMap, state.config.baseCurrency, effectiveExchangeRates, isPortfolio, collectionId],
177
+ [baseCurrency, collectionId, exchangeRates, financialsMap, isPortfolio, tickers],
196
178
  );
197
179
 
198
180
  const refreshTimestamp = lastRefreshTimestamp ?? lastRefresh?.getTime() ?? null;
199
181
  const refreshText = refreshTimestamp != null
200
182
  ? new Date(refreshTimestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
201
183
  : "—";
202
- const isRefreshing = state.refreshing.size > 0;
184
+ const isRefreshing = refreshingCount > 0;
203
185
 
204
186
  if (!isPortfolio) {
205
187
  if (totals.watchlistCount === 0) return null;
@@ -224,4 +206,4 @@ export function PortfolioSummaryBar({
224
206
  });
225
207
 
226
208
  return <box height={1}>{renderSummarySegments(segments, width)}</box>;
227
- }
209
+ });
@@ -7,6 +7,7 @@ import {
7
7
  getPortfolioPaneSettings,
8
8
  resolveCollectionPaneId,
9
9
  } from "./settings";
10
+ import { portfolioCliCommand, watchlistCliCommand } from "./cli/commands";
10
11
 
11
12
  export { shouldToggleCashMarginDrawer };
12
13
 
@@ -29,6 +30,10 @@ export const portfolioListPlugin: GloomPlugin = {
29
30
  ),
30
31
  },
31
32
  ],
33
+ cliCommands: [
34
+ portfolioCliCommand,
35
+ watchlistCliCommand,
36
+ ],
32
37
  paneTemplates: [
33
38
  {
34
39
  id: "new-collection-pane",
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getColumnValue, type ColumnContext } from "./metrics";
3
+ import type { ColumnConfig } from "../../../types/config";
4
+ import type { TickerFinancials } from "../../../types/financials";
5
+ import type { TickerRecord } from "../../../types/ticker";
6
+
7
+ const TICKER_COLUMN: ColumnConfig = {
8
+ id: "ticker",
9
+ label: "Ticker",
10
+ width: 12,
11
+ align: "left",
12
+ };
13
+
14
+ const TEST_CONTEXT: ColumnContext = {
15
+ baseCurrency: "USD",
16
+ exchangeRates: new Map(),
17
+ now: Date.now(),
18
+ };
19
+
20
+ function makeTicker(): TickerRecord {
21
+ return {
22
+ metadata: {
23
+ ticker: "AAPL",
24
+ exchange: "NASDAQ",
25
+ currency: "USD",
26
+ name: "Apple",
27
+ portfolios: [],
28
+ watchlists: [],
29
+ positions: [],
30
+ custom: {},
31
+ tags: [],
32
+ },
33
+ };
34
+ }
35
+
36
+ function makeFinancials(marketState?: TickerFinancials["quote"]["marketState"]): TickerFinancials {
37
+ return {
38
+ annualStatements: [],
39
+ quarterlyStatements: [],
40
+ priceHistory: [],
41
+ quote: {
42
+ symbol: "AAPL",
43
+ price: 100,
44
+ currency: "USD",
45
+ change: 1,
46
+ changePercent: 1,
47
+ lastUpdated: Date.now(),
48
+ marketState,
49
+ },
50
+ };
51
+ }
52
+
53
+ describe("portfolio-list metrics", () => {
54
+ test("maps session states to the portfolio status glyphs", () => {
55
+ expect(getColumnValue(TICKER_COLUMN, makeTicker(), makeFinancials("REGULAR"), TEST_CONTEXT).text).toBe("● AAPL");
56
+ expect(getColumnValue(TICKER_COLUMN, makeTicker(), makeFinancials("PRE"), TEST_CONTEXT).text).toBe("◐ AAPL");
57
+ expect(getColumnValue(TICKER_COLUMN, makeTicker(), makeFinancials("POST"), TEST_CONTEXT).text).toBe("◐ AAPL");
58
+ expect(getColumnValue(TICKER_COLUMN, makeTicker(), makeFinancials("CLOSED"), TEST_CONTEXT).text).toBe("○ AAPL");
59
+ expect(getColumnValue(TICKER_COLUMN, makeTicker(), makeFinancials(undefined), TEST_CONTEXT).text).toBe("◌ AAPL");
60
+ });
61
+ });
@@ -5,7 +5,7 @@ import type { TickerRecord } from "../../../types/ticker";
5
5
  import { priceColor } from "../../../theme/colors";
6
6
  import { clampQuoteTimestamp, formatQuoteAgeWithSource } from "../../../utils/quote-time";
7
7
  import { convertCurrency, formatCompact, formatCurrency, formatNumber, formatPercentRaw, formatPrice } from "../../../utils/format";
8
- import { getActiveQuoteDisplay, type ActiveQuoteDisplay } from "../../../utils/market-status";
8
+ import { getActiveQuoteDisplay, marketStateDot, type ActiveQuoteDisplay } from "../../../utils/market-status";
9
9
  import { formatOptionTicker } from "../../../utils/options";
10
10
 
11
11
  export interface ColumnContext {
@@ -191,7 +191,7 @@ export function getColumnValue(
191
191
  switch (col.id) {
192
192
  case "ticker": {
193
193
  const marketState = quote?.marketState;
194
- const statusDot = marketState === "REGULAR" ? "\u25CF" : "\u25CB";
194
+ const statusDot = marketStateDot(marketState);
195
195
  const displayName = ticker.metadata.assetCategory === "OPT"
196
196
  ? formatOptionTicker(ticker.metadata.ticker)
197
197
  : ticker.metadata.ticker;
@@ -0,0 +1,187 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createDefaultConfig } from "../../../types/config";
3
+ import type { TickerRecord } from "../../../types/ticker";
4
+ import {
5
+ createManualPortfolio,
6
+ deleteManualPortfolio,
7
+ isManualPortfolio,
8
+ removeTickerFromPortfolio,
9
+ resolveManualPositionCurrency,
10
+ setManualPortfolioPosition,
11
+ } from "./mutations";
12
+
13
+ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRecord {
14
+ return {
15
+ metadata: {
16
+ ticker: "AAPL",
17
+ exchange: "NASDAQ",
18
+ currency: "USD",
19
+ name: "Apple Inc.",
20
+ portfolios: [],
21
+ watchlists: [],
22
+ positions: [],
23
+ custom: {},
24
+ tags: [],
25
+ ...overrides,
26
+ },
27
+ };
28
+ }
29
+
30
+ describe("portfolio-list mutations", () => {
31
+ test("identifies manual portfolios", () => {
32
+ const config = createDefaultConfig("/tmp/gloomberb-mutations");
33
+ expect(isManualPortfolio(config.portfolios[0]!)).toBe(true);
34
+ expect(isManualPortfolio({
35
+ id: "broker:ibkr",
36
+ name: "IBKR",
37
+ currency: "USD",
38
+ brokerId: "ibkr",
39
+ brokerInstanceId: "ibkr-live",
40
+ })).toBe(false);
41
+ });
42
+
43
+ test("rejects duplicate manual portfolio names", () => {
44
+ const config = createDefaultConfig("/tmp/gloomberb-mutations");
45
+ expect(() => createManualPortfolio(config, "Main Portfolio", config.baseCurrency)).toThrow('Portfolio "Main Portfolio" already exists.');
46
+ });
47
+
48
+ test("deletes a manual portfolio and cleans memberships plus positions", () => {
49
+ const config = createDefaultConfig("/tmp/gloomberb-mutations");
50
+ config.portfolios.push({ id: "research", name: "Research", currency: "USD" });
51
+
52
+ const firstTicker = makeTicker({
53
+ ticker: "AAPL",
54
+ portfolios: ["research"],
55
+ positions: [{
56
+ portfolio: "research",
57
+ shares: 10,
58
+ avgCost: 180,
59
+ currency: "USD",
60
+ broker: "manual",
61
+ }],
62
+ });
63
+ const secondTicker = makeTicker({
64
+ ticker: "MSFT",
65
+ portfolios: ["research", "main"],
66
+ positions: [{
67
+ portfolio: "research",
68
+ shares: 5,
69
+ avgCost: 300,
70
+ currency: "USD",
71
+ broker: "manual",
72
+ }, {
73
+ portfolio: "main",
74
+ shares: 3,
75
+ avgCost: 200,
76
+ currency: "USD",
77
+ broker: "manual",
78
+ }],
79
+ });
80
+
81
+ const result = deleteManualPortfolio(config, [firstTicker, secondTicker], "research");
82
+
83
+ expect(result.config.portfolios.map((portfolio) => portfolio.id)).toEqual(["main"]);
84
+ expect(result.cleanedTickerCount).toBe(2);
85
+ expect(result.removedPositionCount).toBe(2);
86
+ expect(result.tickers).toEqual([
87
+ makeTicker({
88
+ ticker: "AAPL",
89
+ portfolios: [],
90
+ }),
91
+ makeTicker({
92
+ ticker: "MSFT",
93
+ portfolios: ["main"],
94
+ positions: [{
95
+ portfolio: "main",
96
+ shares: 3,
97
+ avgCost: 200,
98
+ currency: "USD",
99
+ broker: "manual",
100
+ }],
101
+ }),
102
+ ]);
103
+ });
104
+
105
+ test("removing a ticker from a portfolio also removes its positions", () => {
106
+ const ticker = makeTicker({
107
+ portfolios: ["main", "research"],
108
+ positions: [{
109
+ portfolio: "research",
110
+ shares: 4,
111
+ avgCost: 100,
112
+ currency: "USD",
113
+ broker: "manual",
114
+ }, {
115
+ portfolio: "main",
116
+ shares: 1,
117
+ avgCost: 90,
118
+ currency: "USD",
119
+ broker: "manual",
120
+ }],
121
+ });
122
+
123
+ const result = removeTickerFromPortfolio(ticker, "research");
124
+
125
+ expect(result.changed).toBe(true);
126
+ expect(result.removedPositionCount).toBe(1);
127
+ expect(result.ticker.metadata.portfolios).toEqual(["main"]);
128
+ expect(result.ticker.metadata.positions).toEqual([{
129
+ portfolio: "main",
130
+ shares: 1,
131
+ avgCost: 90,
132
+ currency: "USD",
133
+ broker: "manual",
134
+ }]);
135
+ });
136
+
137
+ test("setting a manual position replaces the aggregate entry for that portfolio", () => {
138
+ const ticker = makeTicker({
139
+ portfolios: ["main"],
140
+ positions: [{
141
+ portfolio: "main",
142
+ shares: 1,
143
+ avgCost: 100,
144
+ currency: "USD",
145
+ broker: "manual",
146
+ }, {
147
+ portfolio: "other",
148
+ shares: 2,
149
+ avgCost: 50,
150
+ currency: "USD",
151
+ broker: "manual",
152
+ }],
153
+ });
154
+
155
+ const result = setManualPortfolioPosition(ticker, "main", {
156
+ shares: 10,
157
+ avgCost: 150,
158
+ currency: "USD",
159
+ });
160
+
161
+ expect(result.addedMembership).toBe(false);
162
+ expect(result.replacedPositionCount).toBe(1);
163
+ expect(result.ticker.metadata.portfolios).toEqual(["main"]);
164
+ expect(result.ticker.metadata.positions).toEqual([{
165
+ portfolio: "other",
166
+ shares: 2,
167
+ avgCost: 50,
168
+ currency: "USD",
169
+ broker: "manual",
170
+ }, {
171
+ portfolio: "main",
172
+ shares: 10,
173
+ avgCost: 150,
174
+ currency: "USD",
175
+ broker: "manual",
176
+ }]);
177
+ });
178
+
179
+ test("resolves position currency from explicit, ticker, portfolio, or base currency defaults", () => {
180
+ const portfolio = { id: "research", name: "Research", currency: "EUR" };
181
+
182
+ expect(resolveManualPositionCurrency("jpy", makeTicker(), portfolio, "USD")).toBe("JPY");
183
+ expect(resolveManualPositionCurrency("", makeTicker({ currency: "CAD" }), portfolio, "USD")).toBe("CAD");
184
+ expect(resolveManualPositionCurrency("", makeTicker({ currency: "" }), portfolio, "USD")).toBe("EUR");
185
+ expect(resolveManualPositionCurrency("", makeTicker({ currency: "" }), { ...portfolio, currency: "" }, "USD")).toBe("USD");
186
+ });
187
+ });