gloomberb 0.9.6 → 0.9.8

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 (130) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -0,0 +1,400 @@
1
+ import { scheduleConfigSave } from "../state/config-save-scheduler";
2
+ import type { AppConfig, BrokerInstanceConfig } from "../types/config";
3
+ import type { PricePoint, TickerFinancials } from "../types/financials";
4
+ import type { Portfolio, TickerMetadata, TickerPosition, TickerRecord, Watchlist } from "../types/ticker";
5
+ import { hydrateTickerMetadata } from "../tickers/metadata";
6
+ import type { SyncContributor } from "./types";
7
+ import { convertCurrency } from "../utils/format";
8
+ import {
9
+ computeDatedBeta,
10
+ computeDatedReturns,
11
+ computeWeightedPortfolioReturns,
12
+ type WeightedReturnSeries,
13
+ } from "../plugins/builtin/analytics/metrics";
14
+ import { getSyncedProfileAnalytics } from "./profile-analytics";
15
+
16
+ const SENSITIVE_KEY_PATTERN = /(token|secret|password|credential|private|api[_-]?key|access[_-]?key|refresh[_-]?key|session|cookie|dataDir|path|directory|localPath)/i;
17
+
18
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
19
+ return value != null && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ function sanitizeUnknown(value: unknown): unknown {
23
+ if (Array.isArray(value)) {
24
+ return value.map(sanitizeUnknown).filter((entry) => entry !== undefined);
25
+ }
26
+ if (!isPlainObject(value)) return value;
27
+
28
+ const output: Record<string, unknown> = {};
29
+ for (const [key, child] of Object.entries(value)) {
30
+ if (SENSITIVE_KEY_PATTERN.test(key)) continue;
31
+ const sanitized = sanitizeUnknown(child);
32
+ if (sanitized !== undefined) output[key] = sanitized;
33
+ }
34
+ return output;
35
+ }
36
+
37
+ function sanitizePortfolio(portfolio: Portfolio): Portfolio {
38
+ return {
39
+ id: portfolio.id,
40
+ name: portfolio.name,
41
+ description: portfolio.description,
42
+ currency: portfolio.currency,
43
+ brokerId: portfolio.brokerId,
44
+ lastSyncedAt: portfolio.lastSyncedAt,
45
+ };
46
+ }
47
+
48
+ function sanitizeWatchlist(watchlist: Watchlist): Watchlist {
49
+ return {
50
+ id: watchlist.id,
51
+ name: watchlist.name,
52
+ description: watchlist.description,
53
+ };
54
+ }
55
+
56
+ function sanitizeBrokerInstance(instance: BrokerInstanceConfig): Omit<BrokerInstanceConfig, "config"> {
57
+ return {
58
+ id: instance.id,
59
+ brokerType: instance.brokerType,
60
+ label: instance.label,
61
+ connectionMode: instance.connectionMode,
62
+ enabled: instance.enabled,
63
+ lastSyncedAt: instance.lastSyncedAt,
64
+ };
65
+ }
66
+
67
+ function sanitizePosition(position: TickerPosition): TickerPosition {
68
+ return {
69
+ portfolio: position.portfolio,
70
+ shares: position.shares,
71
+ avgCost: position.avgCost,
72
+ currency: position.currency,
73
+ dateAcquired: position.dateAcquired,
74
+ broker: position.broker,
75
+ side: position.side,
76
+ marketValue: position.marketValue,
77
+ unrealizedPnl: position.unrealizedPnl,
78
+ multiplier: position.multiplier,
79
+ markPrice: position.markPrice,
80
+ };
81
+ }
82
+
83
+ function pricePointTime(point: { date: Date | string | number }): number {
84
+ const value = point.date;
85
+ if (value instanceof Date) return value.getTime();
86
+ return new Date(value).getTime();
87
+ }
88
+
89
+ function weeklyQuoteMove(financials: TickerFinancials | null | undefined) {
90
+ const quote = financials?.quote;
91
+ if (!quote || !Number.isFinite(quote.price) || quote.price <= 0) return undefined;
92
+ const history = (financials?.priceHistory ?? [])
93
+ .filter((point) => Number.isFinite(point.close) && point.close > 0 && Number.isFinite(pricePointTime(point)))
94
+ .sort((left, right) => pricePointTime(left) - pricePointTime(right));
95
+ if (history.length === 0) return undefined;
96
+ const latestTime = Number.isFinite(quote.lastUpdated)
97
+ ? quote.lastUpdated
98
+ : pricePointTime(history[history.length - 1]!);
99
+ const cutoff = latestTime - 7 * 24 * 60 * 60 * 1000;
100
+ const reference = history.find((point) => pricePointTime(point) >= cutoff) ?? history[Math.max(0, history.length - 7)];
101
+ if (!reference || !Number.isFinite(reference.close) || reference.close <= 0) return undefined;
102
+ const weekChange = quote.price - reference.close;
103
+ return {
104
+ weekReferencePrice: reference.close,
105
+ weekChange,
106
+ weekChangePercent: (weekChange / reference.close) * 100,
107
+ };
108
+ }
109
+
110
+ function sanitizeQuote(financials: TickerFinancials | null | undefined) {
111
+ const quote = financials?.quote;
112
+ if (!quote) return undefined;
113
+ const week = weeklyQuoteMove(financials);
114
+ return {
115
+ price: Number.isFinite(quote.price) ? quote.price : undefined,
116
+ currency: quote.currency,
117
+ changePercent: Number.isFinite(quote.changePercent) ? quote.changePercent : undefined,
118
+ previousClose: Number.isFinite(quote.previousClose) ? quote.previousClose : undefined,
119
+ weekReferencePrice: week?.weekReferencePrice,
120
+ weekChange: week?.weekChange,
121
+ weekChangePercent: week?.weekChangePercent,
122
+ lastUpdated: quote.lastUpdated,
123
+ };
124
+ }
125
+
126
+ function sanitizeTickerMetadata(metadata: TickerMetadata, financials?: TickerFinancials | null): Omit<TickerMetadata, "broker_contracts"> & { quote?: ReturnType<typeof sanitizeQuote> } {
127
+ return {
128
+ ticker: metadata.ticker,
129
+ exchange: metadata.exchange,
130
+ currency: metadata.currency,
131
+ name: metadata.name,
132
+ sector: metadata.sector,
133
+ industry: metadata.industry,
134
+ assetCategory: metadata.assetCategory,
135
+ isin: metadata.isin,
136
+ cusip: metadata.cusip,
137
+ portfolios: [...metadata.portfolios],
138
+ watchlists: [...metadata.watchlists],
139
+ positions: metadata.positions.map(sanitizePosition),
140
+ custom: (sanitizeUnknown(metadata.custom) as Record<string, unknown>) ?? {},
141
+ tags: [...metadata.tags],
142
+ quote: sanitizeQuote(financials),
143
+ };
144
+ }
145
+
146
+ function collectCoreConfigPayload(config: AppConfig) {
147
+ return sanitizeUnknown({
148
+ configVersion: config.configVersion,
149
+ baseCurrency: config.baseCurrency,
150
+ refreshIntervalMinutes: config.refreshIntervalMinutes,
151
+ portfolios: config.portfolios.map(sanitizePortfolio),
152
+ watchlists: config.watchlists.map(sanitizeWatchlist),
153
+ layout: config.layout,
154
+ layouts: config.layouts,
155
+ activeLayoutIndex: config.activeLayoutIndex,
156
+ brokerInstances: config.brokerInstances.map(sanitizeBrokerInstance),
157
+ disabledPlugins: config.disabledPlugins,
158
+ disabledSources: config.disabledSources,
159
+ pluginConfig: config.pluginConfig,
160
+ theme: config.theme,
161
+ chartPreferences: config.chartPreferences,
162
+ valueFlashingEnabled: config.valueFlashingEnabled,
163
+ recentTickers: config.recentTickers,
164
+ onboardingComplete: config.onboardingComplete,
165
+ });
166
+ }
167
+
168
+ function positionValueBase(
169
+ position: TickerPosition,
170
+ financials: TickerFinancials | null | undefined,
171
+ baseCurrency: string,
172
+ exchangeRates: Map<string, number>,
173
+ ): number | null {
174
+ const quoteCurrency = financials?.quote?.currency || position.currency || baseCurrency;
175
+ const positionCurrency = position.currency || quoteCurrency;
176
+ if (typeof position.marketValue === "number" && Number.isFinite(position.marketValue)) {
177
+ return convertCurrency(Math.abs(position.marketValue), positionCurrency, baseCurrency, exchangeRates);
178
+ }
179
+ const hasMarkPrice = typeof position.markPrice === "number" && Number.isFinite(position.markPrice);
180
+ const hasQuotePrice = typeof financials?.quote?.price === "number" && Number.isFinite(financials.quote.price);
181
+ const price = hasMarkPrice ? position.markPrice : hasQuotePrice ? financials!.quote!.price : position.avgCost;
182
+ if (!Number.isFinite(position.shares) || typeof price !== "number" || !Number.isFinite(price)) return null;
183
+ return convertCurrency(
184
+ Math.abs(position.shares * price * (position.multiplier ?? 1)),
185
+ hasQuotePrice && !hasMarkPrice ? quoteCurrency : positionCurrency,
186
+ baseCurrency,
187
+ exchangeRates,
188
+ );
189
+ }
190
+
191
+ function pricePointTimeOrNull(point: PricePoint): number | null {
192
+ const value = point.date;
193
+ if (value == null) return null;
194
+ const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
195
+ return Number.isFinite(time) ? time : null;
196
+ }
197
+
198
+ function recentPriceHistory(history: PricePoint[], days: number): PricePoint[] {
199
+ let latestTime = 0;
200
+ for (const point of history) {
201
+ const time = pricePointTimeOrNull(point);
202
+ if (time != null && time > latestTime) latestTime = time;
203
+ }
204
+ if (latestTime <= 0) return [];
205
+ const cutoff = latestTime - days * 24 * 60 * 60 * 1000;
206
+ return history.filter((point) => {
207
+ const time = pricePointTimeOrNull(point);
208
+ return time != null && time >= cutoff;
209
+ });
210
+ }
211
+
212
+ function oneYearReturnFromSeries(series: WeightedReturnSeries[]): number | null {
213
+ const returns = computeWeightedPortfolioReturns(series);
214
+ if (returns.length === 0) return null;
215
+ const cumulative = returns.reduce((acc, point) => (
216
+ Number.isFinite(point.value) ? acc * (1 + point.value) : acc
217
+ ), 1) - 1;
218
+ return Number.isFinite(cumulative) ? cumulative : null;
219
+ }
220
+
221
+ function collectAnalyticsByPortfolio(
222
+ config: AppConfig,
223
+ tickers: Map<string, TickerRecord>,
224
+ financials: Map<string, TickerFinancials>,
225
+ exchangeRates: Map<string, number>,
226
+ ) {
227
+ const output: Record<string, Record<string, unknown>> = {};
228
+ const spyReturns = computeDatedReturns(recentPriceHistory(financials.get("SPY")?.priceHistory ?? [], 366));
229
+ for (const portfolio of config.portfolios) {
230
+ let returnWeight = 0;
231
+ let weightedReturn = 0;
232
+ const datedReturnSeries: WeightedReturnSeries[] = [];
233
+ for (const ticker of tickers.values()) {
234
+ const tickerFinancials = financials.get(ticker.metadata.ticker);
235
+ const portfolioPositions = ticker.metadata.positions.filter((position) => position.portfolio === portfolio.id);
236
+ if (portfolioPositions.length === 0 && !ticker.metadata.portfolios.includes(portfolio.id)) continue;
237
+ for (const position of portfolioPositions) {
238
+ const value = positionValueBase(position, tickerFinancials, config.baseCurrency, exchangeRates);
239
+ if (value != null) {
240
+ const return1Y = tickerFinancials?.fundamentals?.return1Y;
241
+ if (typeof return1Y === "number" && Number.isFinite(return1Y)) {
242
+ returnWeight += value;
243
+ weightedReturn += return1Y * value;
244
+ }
245
+ const returns = computeDatedReturns(recentPriceHistory(tickerFinancials?.priceHistory ?? [], 366));
246
+ if (returns.length >= 10) datedReturnSeries.push({ weight: value, returns });
247
+ }
248
+ }
249
+ }
250
+ const portfolioReturns = computeWeightedPortfolioReturns(datedReturnSeries);
251
+ const previewAnalytics = getSyncedProfileAnalytics(portfolio.id);
252
+ output[portfolio.id] = {
253
+ oneYearReturn: previewAnalytics?.oneYearReturn
254
+ ?? (returnWeight > 0 ? weightedReturn / returnWeight : oneYearReturnFromSeries(datedReturnSeries)),
255
+ spyBeta: previewAnalytics?.spyBeta
256
+ ?? (
257
+ portfolioReturns.length > 0 && spyReturns.length > 0
258
+ ? computeDatedBeta(portfolioReturns, spyReturns)
259
+ : null
260
+ ),
261
+ };
262
+ }
263
+ return output;
264
+ }
265
+
266
+ function collectCoreCollectionsPayload(
267
+ config: AppConfig,
268
+ tickers: Map<string, TickerRecord>,
269
+ financials: Map<string, TickerFinancials>,
270
+ exchangeRates: Map<string, number>,
271
+ ) {
272
+ const records = [...tickers.values()]
273
+ .map((ticker) => sanitizeTickerMetadata(ticker.metadata, financials.get(ticker.metadata.ticker)))
274
+ .filter((metadata) => (
275
+ metadata.portfolios.length > 0 ||
276
+ metadata.watchlists.length > 0 ||
277
+ metadata.positions.length > 0
278
+ ));
279
+
280
+ return {
281
+ baseCurrency: config.baseCurrency,
282
+ exchangeRates: Object.fromEntries(
283
+ [...exchangeRates.entries()]
284
+ .filter(([currency, rate]) => (
285
+ typeof currency === "string" &&
286
+ Number.isFinite(rate) &&
287
+ rate > 0
288
+ ))
289
+ .map(([currency, rate]) => [currency.trim().toUpperCase(), rate]),
290
+ ),
291
+ portfolios: config.portfolios.map(sanitizePortfolio),
292
+ watchlists: config.watchlists.map(sanitizeWatchlist),
293
+ analyticsByPortfolio: collectAnalyticsByPortfolio(config, tickers, financials, exchangeRates),
294
+ tickers: records,
295
+ };
296
+ }
297
+
298
+ function mergeConfigPayload(config: AppConfig, payload: unknown): AppConfig | null {
299
+ if (!isPlainObject(payload)) return null;
300
+ const next: AppConfig = { ...config };
301
+ const assign = <K extends keyof AppConfig>(key: K) => {
302
+ if (key in payload) {
303
+ next[key] = payload[key as string] as AppConfig[K];
304
+ }
305
+ };
306
+
307
+ assign("baseCurrency");
308
+ assign("refreshIntervalMinutes");
309
+ assign("layout");
310
+ assign("layouts");
311
+ assign("activeLayoutIndex");
312
+ assign("disabledPlugins");
313
+ assign("disabledSources");
314
+ assign("pluginConfig");
315
+ assign("theme");
316
+ assign("chartPreferences");
317
+ assign("valueFlashingEnabled");
318
+ assign("recentTickers");
319
+ assign("onboardingComplete");
320
+
321
+ if (Array.isArray(payload.portfolios)) next.portfolios = payload.portfolios as Portfolio[];
322
+ if (Array.isArray(payload.watchlists)) next.watchlists = payload.watchlists as Watchlist[];
323
+ if (Array.isArray(payload.brokerInstances)) {
324
+ const incoming = payload.brokerInstances as Array<Partial<BrokerInstanceConfig>>;
325
+ const existingById = new Map(config.brokerInstances.map((instance) => [instance.id, instance]));
326
+ next.brokerInstances = incoming.map((instance) => {
327
+ const current = instance.id ? existingById.get(instance.id) : undefined;
328
+ return {
329
+ id: instance.id ?? current?.id ?? crypto.randomUUID(),
330
+ brokerType: instance.brokerType ?? current?.brokerType ?? "",
331
+ label: instance.label ?? current?.label ?? "",
332
+ connectionMode: instance.connectionMode ?? current?.connectionMode,
333
+ enabled: instance.enabled ?? current?.enabled,
334
+ lastSyncedAt: instance.lastSyncedAt ?? current?.lastSyncedAt,
335
+ config: current?.config ?? {},
336
+ };
337
+ });
338
+ }
339
+ return next;
340
+ }
341
+
342
+ export const coreConfigSyncContributor: SyncContributor = {
343
+ id: "core.config",
344
+ schemaVersion: 1,
345
+ collect: ({ state }) => collectCoreConfigPayload(state.config),
346
+ apply: (payload, { state, dispatch }) => {
347
+ const nextConfig = mergeConfigPayload(state.config, payload);
348
+ if (!nextConfig) return;
349
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
350
+ scheduleConfigSave(nextConfig);
351
+ },
352
+ };
353
+
354
+ export const coreCollectionsSyncContributor: SyncContributor = {
355
+ id: "core.collections",
356
+ schemaVersion: 1,
357
+ collect: ({ state }) => collectCoreCollectionsPayload(
358
+ state.config,
359
+ state.tickers,
360
+ state.financials,
361
+ state.exchangeRates,
362
+ ),
363
+ apply: async (payload, { state, dispatch, tickerRepository }) => {
364
+ if (!isPlainObject(payload)) return;
365
+ const nextConfig = { ...state.config };
366
+ if (Array.isArray(payload.portfolios)) nextConfig.portfolios = payload.portfolios as Portfolio[];
367
+ if (Array.isArray(payload.watchlists)) nextConfig.watchlists = payload.watchlists as Watchlist[];
368
+
369
+ const nextTickers = new Map(state.tickers);
370
+ const rawTickers = Array.isArray(payload.tickers) ? payload.tickers : [];
371
+ for (const rawTicker of rawTickers) {
372
+ if (!isPlainObject(rawTicker)) continue;
373
+ const current = typeof rawTicker.ticker === "string"
374
+ ? nextTickers.get(rawTicker.ticker)
375
+ : null;
376
+ const metadata = hydrateTickerMetadata({
377
+ ...current?.metadata,
378
+ ...rawTicker,
379
+ broker_contracts: current?.metadata.broker_contracts ?? [],
380
+ });
381
+ const record: TickerRecord = { metadata };
382
+ await tickerRepository.saveTicker(record);
383
+ nextTickers.set(metadata.ticker, record);
384
+ }
385
+
386
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
387
+ dispatch({ type: "SET_TICKERS", tickers: nextTickers });
388
+ scheduleConfigSave(nextConfig);
389
+ },
390
+ };
391
+
392
+ export function createCoreSyncContributors(): SyncContributor[] {
393
+ return [coreConfigSyncContributor, coreCollectionsSyncContributor];
394
+ }
395
+
396
+ export const __syncContributorInternalsForTests = {
397
+ sanitizeUnknown,
398
+ collectCoreConfigPayload,
399
+ collectCoreCollectionsPayload,
400
+ };
@@ -0,0 +1,39 @@
1
+ import type { PublicPortfolioAnalytics } from "../api-client";
2
+
3
+ const analyticsByPortfolio = new Map<string, PublicPortfolioAnalytics>();
4
+
5
+ function finiteMetric(value: number | null | undefined): number | null {
6
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
7
+ }
8
+
9
+ function normalizeAnalytics(analytics: PublicPortfolioAnalytics | null | undefined): PublicPortfolioAnalytics | null {
10
+ if (!analytics) return null;
11
+ const normalized = {
12
+ oneYearReturn: finiteMetric(analytics.oneYearReturn),
13
+ spyBeta: finiteMetric(analytics.spyBeta),
14
+ };
15
+ return normalized.oneYearReturn != null || normalized.spyBeta != null ? normalized : null;
16
+ }
17
+
18
+ function sameAnalytics(left: PublicPortfolioAnalytics | null, right: PublicPortfolioAnalytics | null): boolean {
19
+ return left?.oneYearReturn === right?.oneYearReturn && left?.spyBeta === right?.spyBeta;
20
+ }
21
+
22
+ export function setSyncedProfileAnalytics(
23
+ portfolioId: string,
24
+ analytics: PublicPortfolioAnalytics | null | undefined,
25
+ ): boolean {
26
+ const normalized = normalizeAnalytics(analytics);
27
+ const current = analyticsByPortfolio.get(portfolioId) ?? null;
28
+ if (sameAnalytics(current, normalized)) return false;
29
+ if (normalized) {
30
+ analyticsByPortfolio.set(portfolioId, normalized);
31
+ } else {
32
+ analyticsByPortfolio.delete(portfolioId);
33
+ }
34
+ return true;
35
+ }
36
+
37
+ export function getSyncedProfileAnalytics(portfolioId: string): PublicPortfolioAnalytics | null {
38
+ return analyticsByPortfolio.get(portfolioId) ?? null;
39
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useSyncExternalStore, type Dispatch } from "react";
2
+ import type { AppAction, AppState } from "../core/state/app/state";
3
+ import type { TickerRepository } from "../data/ticker-repository";
4
+ import type { PluginRegistry } from "../plugins/registry";
5
+ import { cloudSyncController } from "./controller";
6
+
7
+ interface CloudSyncRuntimeOptions {
8
+ state: AppState;
9
+ dispatch: Dispatch<AppAction>;
10
+ tickerRepository: TickerRepository;
11
+ pluginRegistry: PluginRegistry;
12
+ initialized: boolean;
13
+ }
14
+
15
+ export function useCloudSyncRuntime({
16
+ state,
17
+ dispatch,
18
+ tickerRepository,
19
+ pluginRegistry,
20
+ initialized,
21
+ }: CloudSyncRuntimeOptions): void {
22
+ useEffect(() => {
23
+ cloudSyncController.setRuntime({
24
+ state,
25
+ dispatch,
26
+ tickerRepository,
27
+ getContributors: () => pluginRegistry.getEnabledSyncContributors(),
28
+ getTransport: () => pluginRegistry.getActiveSyncTransport(),
29
+ });
30
+ }, [dispatch, pluginRegistry, state, tickerRepository]);
31
+
32
+ useEffect(() => () => {
33
+ cloudSyncController.clearRuntime();
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ if (!initialized) return;
38
+ void cloudSyncController.pullLatest();
39
+ }, [initialized, pluginRegistry]);
40
+
41
+ useEffect(() => {
42
+ if (!initialized) return;
43
+ cloudSyncController.schedulePush("state-change");
44
+ }, [initialized, state.config, state.tickers]);
45
+ }
46
+
47
+ export function useCloudSyncStatus() {
48
+ return useSyncExternalStore(
49
+ (listener) => cloudSyncController.subscribe(listener),
50
+ () => cloudSyncController.getStatus(),
51
+ () => cloudSyncController.getStatus(),
52
+ );
53
+ }
@@ -0,0 +1,77 @@
1
+ import type { Dispatch } from "react";
2
+ import type { AppAction, AppState } from "../core/state/app/state";
3
+ import type { TickerRepository } from "../data/ticker-repository";
4
+
5
+ export const SYNC_SNAPSHOT_SCHEMA_VERSION = 1;
6
+
7
+ export interface SyncContributorPayload {
8
+ schemaVersion: number;
9
+ updatedAt: string;
10
+ payload: unknown;
11
+ }
12
+
13
+ export interface SyncSnapshot {
14
+ schemaVersion: typeof SYNC_SNAPSHOT_SCHEMA_VERSION;
15
+ appId: "gloomberb";
16
+ clientId: string;
17
+ createdAt: string;
18
+ appVersion?: string;
19
+ contributors: Record<string, SyncContributorPayload>;
20
+ }
21
+
22
+ export interface SyncCollectContext {
23
+ state: AppState;
24
+ }
25
+
26
+ export interface SyncApplyContext {
27
+ snapshot: SyncSnapshot;
28
+ state: AppState;
29
+ dispatch: Dispatch<AppAction>;
30
+ tickerRepository: TickerRepository;
31
+ }
32
+
33
+ export interface SyncContributor {
34
+ id: string;
35
+ schemaVersion: number;
36
+ collect(context: SyncCollectContext): unknown | Promise<unknown>;
37
+ apply?(payload: unknown, context: SyncApplyContext): void | Promise<void>;
38
+ }
39
+
40
+ export interface SyncSnapshotResponse {
41
+ snapshot: SyncSnapshot | null;
42
+ revision: number | null;
43
+ updatedAt: string | null;
44
+ settings?: SyncSettings;
45
+ }
46
+
47
+ export interface SyncPushResult {
48
+ revision: number;
49
+ updatedAt: string;
50
+ settings?: SyncSettings;
51
+ }
52
+
53
+ export interface SyncTransport {
54
+ id: string;
55
+ isAvailable(): boolean;
56
+ pullSnapshot(): Promise<SyncSnapshotResponse>;
57
+ pushSnapshot(snapshot: SyncSnapshot, options?: { baseRevision?: number | null }): Promise<SyncPushResult>;
58
+ }
59
+
60
+ export interface SyncSettings {
61
+ syncEnabled: boolean;
62
+ weeklyRoundupEnabled: boolean;
63
+ positionAlertsEnabled: boolean;
64
+ selectedSharedPortfolioId?: string | null;
65
+ lastSyncAt?: string | null;
66
+ lastRoundupEmailAt?: string | null;
67
+ }
68
+
69
+ export interface RegisteredSyncContributor {
70
+ pluginId: string;
71
+ contributor: SyncContributor;
72
+ }
73
+
74
+ export interface RegisteredSyncTransport {
75
+ pluginId: string;
76
+ transport: SyncTransport;
77
+ }
@@ -216,6 +216,7 @@ const DEFAULT_HOME_LAYOUT: LayoutConfig = {
216
216
  columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
217
217
  collectionScope: "all",
218
218
  visibleCollectionIds: [],
219
+ viewMode: "table",
219
220
  },
220
221
  binding: { kind: "none" },
221
222
  },
@@ -0,0 +1,7 @@
1
+ export interface DesktopDeepLink {
2
+ url: string;
3
+ }
4
+
5
+ export interface DesktopDeepLinkBridge {
6
+ subscribe(listener: (deeplink: DesktopDeepLink) => void): () => void;
7
+ }
@@ -20,6 +20,7 @@ import type { TickerFinancials } from "./financials";
20
20
  import type { CachePolicy, PersistedResourceValue } from "./persistence";
21
21
  import type { TickerRecord } from "./ticker";
22
22
  import type { InstrumentSearchResult } from "./instrument";
23
+ import type { SyncContributor, SyncTransport } from "../sync/types";
23
24
 
24
25
  export interface GloomSlots {
25
26
  "ticker-research:tab": { ticker: TickerRecord; financials: TickerFinancials | null };
@@ -467,6 +468,8 @@ export interface GloomPluginContext {
467
468
  registerShortcut(shortcut: KeyboardShortcut): void;
468
469
  registerTickerAction(action: TickerAction): void;
469
470
  registerContextMenuProvider(provider: ContextMenuProviderDef): void;
471
+ registerSyncContributor(contributor: SyncContributor): () => void;
472
+ registerSyncTransport(transport: SyncTransport): () => void;
470
473
  watchNewsQuery?(
471
474
  query: import("./news-source").NewsQuery,
472
475
  listener: (state: import("./news-source").NewsQueryState) => void,
package/src/ui/host.tsx CHANGED
@@ -85,7 +85,7 @@ export interface SyntaxStyleLike {
85
85
  export interface BoxRenderable {
86
86
  x?: number;
87
87
  y?: number;
88
- width?: number;
88
+ width?: number | string;
89
89
  height?: number;
90
90
  absoluteX?: number;
91
91
  absoluteY?: number;
@@ -264,6 +264,18 @@ export interface HostTabsProps {
264
264
  palette: HostTabsPalette;
265
265
  }
266
266
 
267
+ export interface HostCheckboxProps {
268
+ label: string;
269
+ displayLabel?: string;
270
+ checked: boolean;
271
+ onChange?: (checked: boolean) => void;
272
+ disabled?: boolean;
273
+ active?: boolean;
274
+ description?: string;
275
+ width?: number;
276
+ variant?: "default" | "desktop";
277
+ }
278
+
267
279
  export interface UiHost {
268
280
  kind?: "opentui" | "desktop-web";
269
281
  capabilities?: {
@@ -299,6 +311,7 @@ export interface UiHost {
299
311
  DialogFrame?: ComponentType<any>;
300
312
  PageStackView?: ComponentType<any>;
301
313
  Tabs?: ComponentType<HostTabsProps>;
314
+ Checkbox?: ComponentType<HostCheckboxProps>;
302
315
  DataTable?: ComponentType<any>;
303
316
  createSyntaxStyle?(): SyntaxStyleLike;
304
317
  colorFromHex?(hex: string): unknown;
package/src/ui/index.tsx CHANGED
@@ -35,6 +35,7 @@ export { contextMenuDivider } from "../types/context-menu";
35
35
  export type {
36
36
  BoxRenderable,
37
37
  ChartSurfaceProps,
38
+ HostCheckboxProps,
38
39
  Highlight,
39
40
  ImageSurfaceProps,
40
41
  InputRenderable,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { CANONICAL_EXCHANGE_ALIASES, EXCHANGE_TIME_ZONES } from "./exchanges";
2
+ import { canonicalExchange, CANONICAL_EXCHANGE_ALIASES, EXCHANGE_TIME_ZONES, resolveExchangeTimeZone } from "./exchanges";
3
3
 
4
4
  describe("exchange metadata", () => {
5
5
  test("has a valid timezone for every canonical exchange", () => {
@@ -13,4 +13,14 @@ describe("exchange metadata", () => {
13
13
  })).not.toThrow();
14
14
  }
15
15
  });
16
+
17
+ test("normalizes broker venue aliases used by portfolio rows", () => {
18
+ expect(canonicalExchange("LSEETF")).toBe("LSE");
19
+ expect(canonicalExchange("TSE")).toBe("TSX");
20
+ expect(canonicalExchange("AEB")).toBe("AMS");
21
+ expect(canonicalExchange("EURONEXT")).toBe("AMS");
22
+ expect(resolveExchangeTimeZone("LSEETF")).toBe("Europe/London");
23
+ expect(resolveExchangeTimeZone("TSE")).toBe("America/Toronto");
24
+ expect(resolveExchangeTimeZone("AEB")).toBe("Europe/Amsterdam");
25
+ });
16
26
  });