gloomberb 0.9.8 → 0.9.10

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 (181) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +3 -3
  3. package/src/api-client/index.ts +0 -6
  4. package/src/api-client/request.test.ts +63 -0
  5. package/src/api-client/request.ts +53 -5
  6. package/src/api-client/types.ts +0 -2
  7. package/src/app/pane-runtime/layout-placement.ts +0 -1
  8. package/src/app.tsx +1 -0
  9. package/src/brokers/account-cache.ts +2 -10
  10. package/src/brokers/broker-portfolio-sync.ts +0 -1
  11. package/src/brokers/broker-ticker-sync.ts +20 -16
  12. package/src/brokers/remote-broker-adapter.ts +3 -0
  13. package/src/capabilities/registry.test.ts +25 -0
  14. package/src/capabilities/registry.ts +22 -5
  15. package/src/cli/commands/automation.ts +1 -3
  16. package/src/cli/commands/command-utils.ts +0 -7
  17. package/src/cli/commands/overview.ts +2 -2
  18. package/src/cli/commands/plugins.ts +34 -17
  19. package/src/cli/index.ts +2 -2
  20. package/src/cli.test.ts +0 -157
  21. package/src/components/chart/comparison/renderer.ts +0 -1
  22. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  23. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  24. package/src/components/chart/core/index.test.ts +0 -24
  25. package/src/components/chart/core/renderer.ts +0 -4
  26. package/src/components/chart/core/resolution.test.ts +0 -17
  27. package/src/components/chart/core/resolution.ts +0 -26
  28. package/src/components/chart/core/scroll.test.ts +0 -6
  29. package/src/components/chart/core/scroll.ts +0 -22
  30. package/src/components/chart/core/viewport.test.ts +2 -23
  31. package/src/components/chart/core/viewport.ts +0 -27
  32. package/src/components/chart/market-session.ts +1 -1
  33. package/src/components/chart/native/surface/hooks.ts +10 -3
  34. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  35. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  36. package/src/components/command-bar/helpers.test.ts +1 -80
  37. package/src/components/command-bar/helpers.ts +1 -36
  38. package/src/components/command-bar/pane-settings.ts +1 -1
  39. package/src/components/command-bar/panel/runtime.ts +0 -2
  40. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  41. package/src/components/command-bar/surface/index.test.tsx +0 -16
  42. package/src/components/command-bar/surface/index.tsx +0 -1
  43. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  44. package/src/components/feed-data-table/stack-view.tsx +0 -1
  45. package/src/components/layout/shell/index.test.tsx +2 -113
  46. package/src/components/layout/shell/index.tsx +0 -1
  47. package/src/components/layout/shell/menu.ts +0 -1
  48. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  49. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  50. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  51. package/src/components/price-selector-dialog.tsx +3 -3
  52. package/src/components/ticker/badge/text.tsx +0 -1
  53. package/src/components/ui/page-stack-view.tsx +1 -1
  54. package/src/components/ui/ui.test.tsx +0 -78
  55. package/src/core/state/app/layout-reducer.ts +0 -1
  56. package/src/data/config/layout.ts +3 -3
  57. package/src/data/resource-store.ts +6 -0
  58. package/src/data/sqlite/cache.test.ts +5 -0
  59. package/src/data/ticker-store.ts +0 -7
  60. package/src/market-data/coordinator/auxiliary.ts +1 -44
  61. package/src/market-data/coordinator/entries.ts +0 -1
  62. package/src/market-data/coordinator/index.test.ts +0 -10
  63. package/src/market-data/coordinator/index.ts +1 -18
  64. package/src/market-data/coordinator/quotes.ts +0 -6
  65. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  66. package/src/market-data/market/status.ts +19 -1
  67. package/src/market-data/query-store.ts +0 -4
  68. package/src/market-data/quotes/resolution.ts +2 -2
  69. package/src/market-data/request-types.ts +0 -5
  70. package/src/market-data/result-types.ts +0 -11
  71. package/src/market-data/selectors.ts +1 -5
  72. package/src/news/aggregator.ts +5 -1
  73. package/src/pane-settings.test.ts +0 -18
  74. package/src/plugins/builtin/account-management/form-components.tsx +0 -120
  75. package/src/plugins/builtin/account-management/model.ts +3 -27
  76. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  77. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  78. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  79. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  80. package/src/plugins/builtin/buildout/format.ts +2 -11
  81. package/src/plugins/builtin/chat/chat.test.tsx +2 -1
  82. package/src/plugins/builtin/chat/content/index.tsx +1 -2
  83. package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
  84. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  85. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  86. package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
  87. package/src/plugins/builtin/chat/test-harness.tsx +2 -2
  88. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  89. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  90. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  91. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  92. package/src/plugins/builtin/earnings/index.tsx +0 -9
  93. package/src/plugins/builtin/econ/index.tsx +1 -5
  94. package/src/plugins/builtin/help/index.tsx +2 -2
  95. package/src/plugins/builtin/holders/pane.tsx +0 -5
  96. package/src/plugins/builtin/insider/index.tsx +1 -8
  97. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  98. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  99. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  100. package/src/plugins/builtin/news/index.tsx +0 -1
  101. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  102. package/src/plugins/builtin/options/view.test.tsx +1 -38
  103. package/src/plugins/builtin/plugin-groups.ts +17 -3
  104. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  105. package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
  106. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  107. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  108. package/src/plugins/builtin/sec/index.tsx +0 -2
  109. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  110. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  111. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  112. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
  113. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  114. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  115. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  116. package/src/plugins/builtin/world-indices/model.ts +0 -7
  117. package/src/plugins/ibkr/account-cache.ts +1 -1
  118. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  119. package/src/plugins/ibkr/trade/utils.ts +1 -7
  120. package/src/plugins/loader.ts +2 -1
  121. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  122. package/src/plugins/registry/contributions.ts +22 -11
  123. package/src/plugins/registry/index.test.ts +44 -0
  124. package/src/plugins/registry/index.ts +44 -26
  125. package/src/remote/controller.ts +1 -7
  126. package/src/remote/json-patch.ts +15 -1
  127. package/src/remote/server.ts +16 -6
  128. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  129. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  130. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  131. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  132. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  133. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  134. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  135. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  136. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  137. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  138. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  139. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  140. package/src/renderers/opentui/host.tsx +11 -3
  141. package/src/renderers/opentui/input-host.tsx +5 -1
  142. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  143. package/src/sources/gloomberb-cloud/index.ts +1 -30
  144. package/src/sources/gloomberb-cloud/news.ts +0 -12
  145. package/src/sources/provider-router/financial-routes.ts +25 -3
  146. package/src/sources/provider-router/index.test.ts +167 -0
  147. package/src/sources/provider-router/search.ts +5 -0
  148. package/src/sources/provider-router/supplemental.ts +4 -3
  149. package/src/sources/yahoo-finance/history.ts +5 -1
  150. package/src/sources/yahoo-finance/options.ts +59 -14
  151. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  152. package/src/sources/yahoo-finance.test.ts +40 -21
  153. package/src/sources/yahoo-finance.ts +13 -8
  154. package/src/state/app/context/index.test.tsx +0 -81
  155. package/src/state/app/context/selector.test.tsx +6 -2
  156. package/src/sync/controller.test.ts +151 -0
  157. package/src/sync/controller.ts +92 -57
  158. package/src/sync/core-contributors.ts +45 -21
  159. package/src/sync/react.ts +6 -8
  160. package/src/sync/types.ts +3 -0
  161. package/src/test-support/data-provider.ts +0 -1
  162. package/src/ui/host.tsx +2 -1
  163. package/src/ui/index.tsx +1 -0
  164. package/src/updater.test.ts +0 -21
  165. package/src/updater.ts +20 -16
  166. package/src/utils/async-deadline.ts +26 -0
  167. package/src/utils/selection-clipboard.ts +2 -2
  168. package/src/version.ts +1 -1
  169. package/src/components/layout/pane/header.test.tsx +0 -39
  170. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  171. package/src/plugins/builtin/chat/index.tsx +0 -4
  172. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  173. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  174. package/src/plugins/builtin/help/index.test.tsx +0 -242
  175. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  176. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  177. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  178. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  179. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  180. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  181. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { MarketDataRequestContext } from "../../types/data-provider";
2
- import type { OptionContract, OptionsChain, Quote } from "../../types/financials";
2
+ import type { MarketState, OptionContract, OptionsChain, Quote } from "../../types/financials";
3
3
  import { parseOptionSymbol } from "../../utils/options";
4
4
  import { getYahooSymbolsToTry } from "./symbols";
5
5
 
@@ -14,16 +14,40 @@ interface LoadYahooOptionsChainOptions {
14
14
 
15
15
  interface GetYahooOptionQuoteOptions {
16
16
  context?: MarketDataRequestContext;
17
- getOptionsChain: (
17
+ getOptionsChainResult: (
18
18
  ticker: string,
19
19
  exchange?: string,
20
20
  expirationDate?: number,
21
21
  context?: MarketDataRequestContext,
22
- ) => Promise<OptionsChain>;
22
+ ) => Promise<YahooOptionsChainResult>;
23
23
  providerId: string;
24
24
  ticker: string;
25
25
  }
26
26
 
27
+ export interface YahooOptionsChainResult {
28
+ chain: OptionsChain;
29
+ underlyingMarketState?: MarketState;
30
+ }
31
+
32
+ const MARKET_STATES = new Set<MarketState>([
33
+ "PRE",
34
+ "REGULAR",
35
+ "POST",
36
+ "PREPRE",
37
+ "POSTPOST",
38
+ "CLOSED",
39
+ ]);
40
+
41
+ function normalizeYahooMarketState(value: unknown): MarketState | undefined {
42
+ const normalized = typeof value === "string" ? value.toUpperCase() as MarketState : undefined;
43
+ return normalized && MARKET_STATES.has(normalized) ? normalized : undefined;
44
+ }
45
+
46
+ function deriveOptionMarketState(underlyingMarketState?: MarketState): MarketState | undefined {
47
+ if (!underlyingMarketState) return undefined;
48
+ return underlyingMarketState === "REGULAR" ? "REGULAR" : "CLOSED";
49
+ }
50
+
27
51
  function mapYahooOptionContract(raw: Record<string, any>): OptionContract {
28
52
  return {
29
53
  contractSymbol: raw.contractSymbol ?? "",
@@ -43,12 +67,12 @@ function mapYahooOptionContract(raw: Record<string, any>): OptionContract {
43
67
  };
44
68
  }
45
69
 
46
- export async function loadYahooOptionsChain({
70
+ export async function loadYahooOptionsChainResult({
47
71
  exchange,
48
72
  expirationDate,
49
73
  fetchJsonWithCrumb,
50
74
  ticker,
51
- }: LoadYahooOptionsChainOptions): Promise<OptionsChain> {
75
+ }: LoadYahooOptionsChainOptions): Promise<YahooOptionsChainResult> {
52
76
  const symbolsToTry = getYahooSymbolsToTry(ticker, exchange);
53
77
  let lastError: any;
54
78
 
@@ -62,6 +86,7 @@ export async function loadYahooOptionsChain({
62
86
  result?: Array<{
63
87
  underlyingSymbol?: string;
64
88
  expirationDates?: number[];
89
+ quote?: { marketState?: unknown };
65
90
  options?: Array<{
66
91
  calls?: Array<Record<string, any>>;
67
92
  puts?: Array<Record<string, any>>;
@@ -75,10 +100,13 @@ export async function loadYahooOptionsChain({
75
100
 
76
101
  const opts = result.options?.[0];
77
102
  return {
78
- underlyingSymbol: result.underlyingSymbol ?? symbol,
79
- expirationDates: result.expirationDates ?? [],
80
- calls: (opts?.calls ?? []).map((contract) => mapYahooOptionContract(contract)),
81
- puts: (opts?.puts ?? []).map((contract) => mapYahooOptionContract(contract)),
103
+ chain: {
104
+ underlyingSymbol: result.underlyingSymbol ?? symbol,
105
+ expirationDates: result.expirationDates ?? [],
106
+ calls: (opts?.calls ?? []).map((contract) => mapYahooOptionContract(contract)),
107
+ puts: (opts?.puts ?? []).map((contract) => mapYahooOptionContract(contract)),
108
+ },
109
+ underlyingMarketState: normalizeYahooMarketState(result.quote?.marketState),
82
110
  };
83
111
  } catch (err) {
84
112
  lastError = err;
@@ -87,15 +115,26 @@ export async function loadYahooOptionsChain({
87
115
  throw lastError || new Error(`No options chain for ${ticker}`);
88
116
  }
89
117
 
118
+ export async function loadYahooOptionsChain(
119
+ options: LoadYahooOptionsChainOptions,
120
+ ): Promise<OptionsChain> {
121
+ return (await loadYahooOptionsChainResult(options)).chain;
122
+ }
123
+
90
124
  export async function getYahooOptionQuote({
91
125
  context,
92
- getOptionsChain,
126
+ getOptionsChainResult,
93
127
  providerId,
94
128
  ticker,
95
129
  }: GetYahooOptionQuoteOptions): Promise<Quote> {
96
130
  const parsed = parseOptionSymbol(ticker);
97
131
  if (!parsed) throw new Error(`Unsupported option symbol ${ticker}`);
98
- const chain = await getOptionsChain(parsed.underlying, "", parsed.expTs, context);
132
+ const { chain, underlyingMarketState } = await getOptionsChainResult(
133
+ parsed.underlying,
134
+ "",
135
+ parsed.expTs,
136
+ context,
137
+ );
99
138
  const contracts = parsed.side === "C" ? chain.calls : chain.puts;
100
139
  const contract = contracts.find((candidate) =>
101
140
  Math.abs(candidate.strike - parsed.strike) < 0.001 &&
@@ -110,6 +149,12 @@ export async function getYahooOptionQuote({
110
149
  : contract.ask > 0
111
150
  ? contract.ask
112
151
  : undefined;
152
+ const marketState = deriveOptionMarketState(underlyingMarketState);
153
+ const lastUpdated = mark != null
154
+ ? Date.now()
155
+ : contract.lastTradeDate > 0
156
+ ? contract.lastTradeDate * 1000
157
+ : Date.now();
113
158
  return {
114
159
  symbol: ticker,
115
160
  providerId,
@@ -122,13 +167,13 @@ export async function getYahooOptionQuote({
122
167
  ask: contract.ask,
123
168
  mark,
124
169
  name: contract.contractSymbol,
125
- lastUpdated: contract.lastTradeDate > 0
126
- ? contract.lastTradeDate * 1000
127
- : Date.now(),
170
+ lastUpdated,
128
171
  exchangeName: "OPTIONS",
129
172
  fullExchangeName: "OPTIONS",
130
173
  listingExchangeName: "OPTIONS",
131
174
  listingExchangeFullName: "OPTIONS",
175
+ marketState,
176
+ sessionConfidence: marketState ? "derived" : "unknown",
132
177
  dataSource: "delayed",
133
178
  };
134
179
  }
@@ -29,7 +29,10 @@ interface YahooQuoteSummaryOptions {
29
29
  }
30
30
 
31
31
  interface YahooCorporateActionsOptions extends YahooQuoteSummaryOptions {
32
- fetchChart: (symbol: string, range: string, interval?: string) => Promise<ChartResult>;
32
+ fetchChart: (symbol: string, range: string, interval?: string) => Promise<{
33
+ meta: NonNullable<ChartResult["meta"]>;
34
+ events?: ChartResult["events"];
35
+ }>;
33
36
  }
34
37
 
35
38
  export async function loadYahooHolders({
@@ -135,28 +135,38 @@ describe("YahooFinanceClient exchange aliases", () => {
135
135
 
136
136
  test("maps IBKR option symbols to Yahoo option quote marks", async () => {
137
137
  const provider = new YahooFinanceClient() as any;
138
- provider.getOptionsChain = async (ticker: string, _exchange: string, expirationDate: number) => ({
139
- underlyingSymbol: ticker,
140
- expirationDates: [expirationDate],
141
- calls: [{
142
- contractSymbol: "AMD270917C00230000",
143
- strike: 230,
144
- currency: "USD",
145
- lastPrice: 251,
146
- change: 1.5,
147
- percentChange: 0.6,
148
- volume: 10,
149
- openInterest: 20,
150
- bid: 250.25,
151
- ask: 254.5,
152
- impliedVolatility: 0.4,
153
- inTheMoney: false,
154
- expiration: expirationDate,
155
- lastTradeDate: 1_800_000_000,
156
- }],
157
- puts: [],
138
+ const expiration = 1_821_139_200;
139
+ let underlyingMarketState = "POST";
140
+ provider.http.fetchJsonWithCrumb = async () => ({
141
+ optionChain: {
142
+ result: [{
143
+ underlyingSymbol: "AMD",
144
+ expirationDates: [expiration],
145
+ quote: { marketState: underlyingMarketState },
146
+ options: [{
147
+ calls: [{
148
+ contractSymbol: "AMD270917C00230000",
149
+ strike: 230,
150
+ currency: "USD",
151
+ lastPrice: 251,
152
+ change: 1.5,
153
+ percentChange: 0.6,
154
+ volume: 10,
155
+ openInterest: 20,
156
+ bid: 250.25,
157
+ ask: 254.5,
158
+ impliedVolatility: 0.4,
159
+ inTheMoney: false,
160
+ expiration,
161
+ lastTradeDate: 1_800_000_000,
162
+ }],
163
+ puts: [],
164
+ }],
165
+ }],
166
+ },
158
167
  });
159
168
 
169
+ const requestedAt = Date.now();
160
170
  const quote = await provider.getQuote("AMD 270917C00230000");
161
171
 
162
172
  expect(quote).toMatchObject({
@@ -166,8 +176,17 @@ describe("YahooFinanceClient exchange aliases", () => {
166
176
  bid: 250.25,
167
177
  ask: 254.5,
168
178
  providerId: "yahoo",
179
+ marketState: "CLOSED",
180
+ sessionConfidence: "derived",
181
+ });
182
+ expect(quote.lastUpdated).toBeGreaterThanOrEqual(requestedAt);
183
+
184
+ underlyingMarketState = "REGULAR";
185
+ const regularQuote = await provider.getQuote("AMD 270917C00230000");
186
+ expect(regularQuote).toMatchObject({
187
+ marketState: "REGULAR",
188
+ sessionConfidence: "derived",
169
189
  });
170
- expect(quote.lastUpdated).toBe(1_800_000_000_000);
171
190
  });
172
191
 
173
192
  test("preserves analyst rating price targets from upgrade history", async () => {
@@ -31,6 +31,7 @@ import {
31
31
  import {
32
32
  getYahooOptionQuote,
33
33
  loadYahooOptionsChain,
34
+ loadYahooOptionsChainResult,
34
35
  } from "./yahoo-finance/options";
35
36
  import {
36
37
  loadYahooAnalystResearch,
@@ -155,8 +156,13 @@ export class YahooFinanceClient implements DataProvider {
155
156
  if (parseOptionSymbol(ticker)) {
156
157
  return getYahooOptionQuote({
157
158
  context,
158
- getOptionsChain: (underlying, requestExchange, expirationDate, requestContext) => (
159
- this.getOptionsChain(underlying, requestExchange, expirationDate, requestContext)
159
+ getOptionsChainResult: (underlying, requestExchange, expirationDate) => (
160
+ loadYahooOptionsChainResult({
161
+ exchange: requestExchange ?? "",
162
+ expirationDate,
163
+ fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url),
164
+ ticker: underlying,
165
+ })
160
166
  ),
161
167
  providerId: this.id,
162
168
  ticker,
@@ -189,13 +195,12 @@ export class YahooFinanceClient implements DataProvider {
189
195
  fromCurrency = normalized;
190
196
  if (fromCurrency === "USD") return 1;
191
197
 
192
- try {
193
- const { meta, history } = await this.fetchChart(`${fromCurrency}USD=X`, "1mo");
194
- const rate = meta.regularMarketPrice || history[history.length - 1]?.close || 1;
195
- return rate;
196
- } catch {
197
- return 1;
198
+ const { meta, history } = await this.fetchChart(`${fromCurrency}USD=X`, "1mo");
199
+ const rate = meta.regularMarketPrice ?? history[history.length - 1]?.close;
200
+ if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
201
+ throw new Error(`No exchange rate data for ${fromCurrency}/USD`);
198
202
  }
203
+ return rate;
199
204
  }
200
205
 
201
206
  /** Search for a ticker by name/symbol - uses direct fetch (no retry) for speed */
@@ -4,87 +4,6 @@ import { appReducer, createInitialState } from "./index";
4
4
  import { removePane } from "../../../plugins/pane-manager";
5
5
 
6
6
  describe("appReducer command bar state", () => {
7
- test("opens the command bar with an explicit query", () => {
8
- const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
9
- const next = appReducer(state, { type: "SET_COMMAND_BAR", open: true, query: "Sync Broker Account" });
10
-
11
- expect(next.commandBarOpen).toBe(true);
12
- expect(next.commandBarQuery).toBe("Sync Broker Account");
13
- });
14
-
15
- test("clears the command bar query when closing", () => {
16
- const state = appReducer(
17
- createInitialState(createDefaultConfig("/tmp/gloomberb-test")),
18
- { type: "SET_COMMAND_BAR", open: true, query: "Disconnect Broker Account" },
19
- );
20
- const next = appReducer(state, { type: "SET_COMMAND_BAR", open: false });
21
-
22
- expect(next.commandBarOpen).toBe(false);
23
- expect(next.commandBarQuery).toBe("");
24
- });
25
-
26
- test("stores and clears command bar launch requests", () => {
27
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
28
- const opened = appReducer(initial, {
29
- type: "SET_COMMAND_BAR",
30
- open: true,
31
- launch: { kind: "plugin-command", commandId: "set-alert" },
32
- });
33
- const relaunched = appReducer(opened, {
34
- type: "SET_COMMAND_BAR",
35
- open: true,
36
- launch: { kind: "plugin-command", commandId: "set-alert" },
37
- });
38
- const closed = appReducer(relaunched, { type: "SET_COMMAND_BAR", open: false });
39
-
40
- expect(opened.commandBarOpen).toBe(true);
41
- expect(opened.commandBarLaunchRequest).toEqual({
42
- kind: "plugin-command",
43
- commandId: "set-alert",
44
- sequence: 1,
45
- });
46
- expect(relaunched.commandBarLaunchRequest?.sequence).toBe(2);
47
- expect(closed.commandBarLaunchRequest).toBeNull();
48
- });
49
-
50
- test("stores ticker search command bar launch requests", () => {
51
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
52
- const opened = appReducer(initial, {
53
- type: "SET_COMMAND_BAR",
54
- open: true,
55
- launch: { kind: "ticker-search", query: "" },
56
- });
57
-
58
- expect(opened.commandBarOpen).toBe(true);
59
- expect(opened.commandBarLaunchRequest).toEqual({
60
- kind: "ticker-search",
61
- query: "",
62
- sequence: 1,
63
- });
64
- });
65
-
66
- test("toggle opens blank and clears on close", () => {
67
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
68
- const opened = appReducer(initial, { type: "TOGGLE_COMMAND_BAR" });
69
- const closed = appReducer({ ...opened, commandBarQuery: "stale" }, { type: "TOGGLE_COMMAND_BAR" });
70
-
71
- expect(opened.commandBarOpen).toBe(true);
72
- expect(opened.commandBarQuery).toBe("");
73
- expect(closed.commandBarOpen).toBe(false);
74
- expect(closed.commandBarQuery).toBe("");
75
- });
76
-
77
- test("updates the live command bar query without closing the overlay", () => {
78
- const state = appReducer(
79
- createInitialState(createDefaultConfig("/tmp/gloomberb-test")),
80
- { type: "SET_COMMAND_BAR", open: true, query: "PL " },
81
- );
82
- const next = appReducer(state, { type: "SET_COMMAND_BAR_QUERY", query: "PL notes" });
83
-
84
- expect(next.commandBarOpen).toBe(true);
85
- expect(next.commandBarQuery).toBe("PL notes");
86
- });
87
-
88
7
  test("re-shows the gridlock tip and refreshes its sequence on every trigger", () => {
89
8
  const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
90
9
 
@@ -82,8 +82,12 @@ function createDesktopBridge(
82
82
  }
83
83
 
84
84
  describe("pane selectors", () => {
85
- afterEach(() => {
86
- testSetup?.renderer.destroy();
85
+ afterEach(async () => {
86
+ if (testSetup) {
87
+ await act(async () => {
88
+ testSetup!.renderer.destroy();
89
+ });
90
+ }
87
91
  testSetup = undefined;
88
92
  capturedDispatch = null;
89
93
  applyTheme("amber");
@@ -0,0 +1,151 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ appReducer,
4
+ createInitialState,
5
+ type AppAction,
6
+ type AppState,
7
+ } from "../core/state/app/state";
8
+ import type { TickerRepository } from "../data/ticker-repository";
9
+ import { createDefaultConfig } from "../types/config";
10
+ import {
11
+ __syncContributorInternalsForTests,
12
+ coreConfigSyncContributor,
13
+ } from "./core-contributors";
14
+ import { CloudSyncController } from "./controller";
15
+ import {
16
+ SYNC_SNAPSHOT_SCHEMA_VERSION,
17
+ type SyncContributor,
18
+ type SyncSnapshot,
19
+ type SyncSnapshotResponse,
20
+ type SyncTransport,
21
+ } from "./types";
22
+
23
+ test("does not push local state when the initial pull fails", async () => {
24
+ let pushes = 0;
25
+ const transport: SyncTransport = {
26
+ id: "failing-pull",
27
+ isAvailable: () => true,
28
+ pullSnapshot: () => Promise.reject(new Error("pull failed")),
29
+ pushSnapshot: async () => {
30
+ pushes += 1;
31
+ return { revision: 1, updatedAt: new Date().toISOString() };
32
+ },
33
+ };
34
+ const controller = new CloudSyncController();
35
+ controller.setRuntime({
36
+ getState: () => ({} as AppState),
37
+ dispatch: () => {},
38
+ tickerRepository: {} as TickerRepository,
39
+ getContributors: () => [],
40
+ getTransport: () => ({ pluginId: "test", transport }),
41
+ });
42
+
43
+ await controller.requestSync({ force: true });
44
+
45
+ expect(pushes).toBe(0);
46
+ expect(controller.getStatus()).toMatchObject({ phase: "error", error: "pull failed" });
47
+ });
48
+
49
+ test("keeps startup layout changes while serializing pull and push", async () => {
50
+ let state = createInitialState(createDefaultConfig("/tmp/gloomberb-sync-controller-test"));
51
+ const dispatch = (action: AppAction) => {
52
+ state = appReducer(state, action);
53
+ };
54
+ let resolvePull!: (response: SyncSnapshotResponse) => void;
55
+ const deferredPull = new Promise<SyncSnapshotResponse>((resolve) => {
56
+ resolvePull = resolve;
57
+ });
58
+ let pulls = 0;
59
+ const pushes: Array<{
60
+ snapshot: SyncSnapshot;
61
+ options?: { baseRevision?: number | null };
62
+ }> = [];
63
+ const transport: SyncTransport = {
64
+ id: "deferred-pull",
65
+ isAvailable: () => true,
66
+ pullSnapshot: () => {
67
+ pulls += 1;
68
+ return deferredPull;
69
+ },
70
+ pushSnapshot: async (snapshot, options) => {
71
+ pushes.push({ snapshot, options });
72
+ return { revision: 8, updatedAt: "2026-07-13T10:00:08.000Z" };
73
+ },
74
+ };
75
+ const contributor: SyncContributor = {
76
+ ...coreConfigSyncContributor,
77
+ apply: (payload, context) => {
78
+ const config = __syncContributorInternalsForTests.mergeConfigPayload(
79
+ context.state.config,
80
+ payload,
81
+ context.baselineState.config,
82
+ );
83
+ if (config) context.dispatch({ type: "SET_CONFIG", config });
84
+ },
85
+ };
86
+ const controller = new CloudSyncController();
87
+ controller.setRuntime({
88
+ getState: () => state,
89
+ dispatch,
90
+ tickerRepository: {} as TickerRepository,
91
+ getContributors: () => [{ pluginId: "test", contributor }],
92
+ getTransport: () => ({ pluginId: "test", transport }),
93
+ });
94
+
95
+ const startupSync = controller.requestSync({ reason: "startup" });
96
+ const startupPane = {
97
+ instanceId: "help:startup",
98
+ paneId: "help",
99
+ binding: { kind: "none" as const },
100
+ };
101
+ const localLayout = {
102
+ ...state.config.layout,
103
+ instances: [...state.config.layout.instances, startupPane],
104
+ floating: [
105
+ ...state.config.layout.floating,
106
+ { instanceId: startupPane.instanceId, x: 4, y: 3, width: 60, height: 20 },
107
+ ],
108
+ };
109
+ dispatch({
110
+ type: "SET_CONFIG",
111
+ config: {
112
+ ...state.config,
113
+ layout: localLayout,
114
+ layouts: state.config.layouts.map((saved, index) => (
115
+ index === state.config.activeLayoutIndex ? { ...saved, layout: localLayout } : saved
116
+ )),
117
+ },
118
+ });
119
+ const queuedPush = controller.requestSync({ reason: "state-change" });
120
+
121
+ const remoteConfig = createDefaultConfig("/remote/path-is-not-synced");
122
+ remoteConfig.theme = "green";
123
+ resolvePull({
124
+ snapshot: {
125
+ schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
126
+ appId: "gloomberb",
127
+ clientId: "remote-client",
128
+ createdAt: "2026-07-13T10:00:07.000Z",
129
+ contributors: {
130
+ "core.config": {
131
+ schemaVersion: 1,
132
+ updatedAt: "2026-07-13T10:00:07.000Z",
133
+ payload: __syncContributorInternalsForTests.collectCoreConfigPayload(remoteConfig),
134
+ },
135
+ },
136
+ },
137
+ revision: 7,
138
+ updatedAt: "2026-07-13T10:00:07.000Z",
139
+ });
140
+ await Promise.all([startupSync, queuedPush]);
141
+
142
+ const pushedConfig = pushes[0]?.snapshot.contributors["core.config"]?.payload as {
143
+ layout: AppState["config"]["layout"];
144
+ };
145
+ expect(pulls).toBe(1);
146
+ expect(pushes).toHaveLength(1);
147
+ expect(pushes[0]?.options).toEqual({ baseRevision: 7 });
148
+ expect(state.config.theme).toBe("green");
149
+ expect(state.config.layout.instances).toContainEqual(startupPane);
150
+ expect(pushedConfig.layout.instances).toContainEqual(startupPane);
151
+ });