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,296 @@
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
+ import {
5
+ SYNC_SNAPSHOT_SCHEMA_VERSION,
6
+ type RegisteredSyncContributor,
7
+ type RegisteredSyncTransport,
8
+ type SyncContributor,
9
+ type SyncSnapshot,
10
+ type SyncTransport,
11
+ } from "./types";
12
+
13
+ export type CloudSyncPhase = "idle" | "disabled" | "syncing" | "synced" | "error";
14
+
15
+ export interface CloudSyncStatus {
16
+ phase: CloudSyncPhase;
17
+ transportId: string | null;
18
+ lastSyncAt: string | null;
19
+ lastPullAt: string | null;
20
+ revision: number | null;
21
+ error: string | null;
22
+ }
23
+
24
+ interface SyncRuntime {
25
+ state: AppState;
26
+ dispatch: Dispatch<AppAction>;
27
+ tickerRepository: TickerRepository;
28
+ getContributors: () => RegisteredSyncContributor[];
29
+ getTransport: () => RegisteredSyncTransport | null;
30
+ }
31
+
32
+ const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
33
+ const PUSH_DEBOUNCE_MS = 2500;
34
+ const PULL_MIN_INTERVAL_MS = 60_000;
35
+
36
+ function nowIso(): string {
37
+ return new Date().toISOString();
38
+ }
39
+
40
+ function stableStringify(value: unknown): string {
41
+ if (value == null || typeof value !== "object") return JSON.stringify(value);
42
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
43
+ const entries = Object.entries(value as Record<string, unknown>)
44
+ .sort(([left], [right]) => left.localeCompare(right))
45
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`);
46
+ return `{${entries.join(",")}}`;
47
+ }
48
+
49
+ function resolveClientId(): string {
50
+ const storage = globalThis.localStorage;
51
+ const existing = storage?.getItem(CLIENT_ID_STORAGE_KEY);
52
+ if (existing) return existing;
53
+ const id = `client_${crypto.randomUUID()}`;
54
+ storage?.setItem(CLIENT_ID_STORAGE_KEY, id);
55
+ return id;
56
+ }
57
+
58
+ class CloudSyncController {
59
+ private runtime: SyncRuntime | null = null;
60
+ private contributors = new Map<string, RegisteredSyncContributor>();
61
+ private transports = new Map<string, RegisteredSyncTransport>();
62
+ private listeners = new Set<() => void>();
63
+ private pushTimer: ReturnType<typeof setTimeout> | null = null;
64
+ private inFlight: Promise<void> | null = null;
65
+ private clientId: string | null = null;
66
+ private lastSignature: string | null = null;
67
+ private lastPulledAtMs = 0;
68
+ private hasPulledForTransport = new Set<string>();
69
+ private status: CloudSyncStatus = {
70
+ phase: "idle",
71
+ transportId: null,
72
+ lastSyncAt: null,
73
+ lastPullAt: null,
74
+ revision: null,
75
+ error: null,
76
+ };
77
+
78
+ registerContributor(pluginId: string, contributor: SyncContributor): () => void {
79
+ this.contributors.set(contributor.id, { pluginId, contributor });
80
+ this.emit();
81
+ return () => {
82
+ const current = this.contributors.get(contributor.id);
83
+ if (current?.contributor === contributor) {
84
+ this.contributors.delete(contributor.id);
85
+ this.emit();
86
+ }
87
+ };
88
+ }
89
+
90
+ registerTransport(pluginId: string, transport: SyncTransport): () => void {
91
+ this.transports.set(transport.id, { pluginId, transport });
92
+ this.emit();
93
+ return () => {
94
+ const current = this.transports.get(transport.id);
95
+ if (current?.transport === transport) {
96
+ this.transports.delete(transport.id);
97
+ this.emit();
98
+ }
99
+ };
100
+ }
101
+
102
+ getRegisteredContributors(): RegisteredSyncContributor[] {
103
+ return [...this.contributors.values()];
104
+ }
105
+
106
+ getRegisteredTransports(): RegisteredSyncTransport[] {
107
+ return [...this.transports.values()];
108
+ }
109
+
110
+ setRuntime(runtime: SyncRuntime): void {
111
+ this.runtime = runtime;
112
+ this.updateAvailability();
113
+ }
114
+
115
+ clearRuntime(): void {
116
+ this.runtime = null;
117
+ if (this.pushTimer) clearTimeout(this.pushTimer);
118
+ this.pushTimer = null;
119
+ }
120
+
121
+ subscribe(listener: () => void): () => void {
122
+ this.listeners.add(listener);
123
+ return () => this.listeners.delete(listener);
124
+ }
125
+
126
+ getStatus(): CloudSyncStatus {
127
+ return this.status;
128
+ }
129
+
130
+ schedulePush(reason: string): void {
131
+ const runtime = this.runtime;
132
+ if (!runtime) return;
133
+ const transport = runtime.getTransport();
134
+ if (!transport?.transport.isAvailable()) {
135
+ this.setStatus({
136
+ phase: "disabled",
137
+ transportId: transport?.transport.id ?? null,
138
+ error: null,
139
+ });
140
+ return;
141
+ }
142
+ if (this.pushTimer) clearTimeout(this.pushTimer);
143
+ this.pushTimer = setTimeout(() => {
144
+ void this.requestSync({ reason });
145
+ }, PUSH_DEBOUNCE_MS);
146
+ }
147
+
148
+ async requestSync(options: { reason?: string; force?: boolean } = {}): Promise<void> {
149
+ if (this.inFlight) return this.inFlight;
150
+ const run = this.syncOnce(options).finally(() => {
151
+ this.inFlight = null;
152
+ });
153
+ this.inFlight = run;
154
+ return run;
155
+ }
156
+
157
+ async pullLatest(options: { force?: boolean } = {}): Promise<void> {
158
+ const runtime = this.runtime;
159
+ if (!runtime) return;
160
+ const transport = runtime.getTransport();
161
+ if (!transport?.transport.isAvailable()) {
162
+ this.setStatus({ phase: "disabled", transportId: transport?.transport.id ?? null, error: null });
163
+ return;
164
+ }
165
+ const currentMs = Date.now();
166
+ if (!options.force && currentMs - this.lastPulledAtMs < PULL_MIN_INTERVAL_MS) return;
167
+ this.lastPulledAtMs = currentMs;
168
+ await this.runPull(runtime, transport.transport);
169
+ }
170
+
171
+ private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
172
+ const runtime = this.runtime;
173
+ if (!runtime) return;
174
+ const transportRegistration = runtime.getTransport();
175
+ if (!transportRegistration?.transport.isAvailable()) {
176
+ this.setStatus({ phase: "disabled", transportId: transportRegistration?.transport.id ?? null, error: null });
177
+ return;
178
+ }
179
+ const transport = transportRegistration.transport;
180
+ if (!this.hasPulledForTransport.has(transport.id)) {
181
+ await this.runPull(runtime, transport);
182
+ this.hasPulledForTransport.add(transport.id);
183
+ }
184
+
185
+ const snapshot = await this.assembleSnapshot(runtime);
186
+ const signature = stableStringify(snapshot.contributors);
187
+ if (!options.force && signature === this.lastSignature) return;
188
+
189
+ this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
190
+ try {
191
+ const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
192
+ this.lastSignature = signature;
193
+ this.setStatus({
194
+ phase: "synced",
195
+ transportId: transport.id,
196
+ revision: result.revision,
197
+ lastSyncAt: result.updatedAt,
198
+ error: null,
199
+ });
200
+ } catch (error) {
201
+ this.setStatus({
202
+ phase: "error",
203
+ transportId: transport.id,
204
+ error: error instanceof Error ? error.message : String(error),
205
+ });
206
+ }
207
+ }
208
+
209
+ private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<void> {
210
+ this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
211
+ try {
212
+ const response = await transport.pullSnapshot();
213
+ this.setStatus({
214
+ phase: response.snapshot ? "synced" : "idle",
215
+ transportId: transport.id,
216
+ revision: response.revision,
217
+ lastPullAt: response.updatedAt ?? nowIso(),
218
+ lastSyncAt: response.updatedAt,
219
+ error: null,
220
+ });
221
+ if (!response.snapshot) return;
222
+ for (const entry of runtime.getContributors()) {
223
+ const payload = response.snapshot.contributors[entry.contributor.id]?.payload;
224
+ if (payload === undefined || !entry.contributor.apply) continue;
225
+ await entry.contributor.apply(payload, {
226
+ snapshot: response.snapshot,
227
+ state: runtime.state,
228
+ dispatch: runtime.dispatch,
229
+ tickerRepository: runtime.tickerRepository,
230
+ });
231
+ }
232
+ } catch (error) {
233
+ this.setStatus({
234
+ phase: "error",
235
+ transportId: transport.id,
236
+ error: error instanceof Error ? error.message : String(error),
237
+ });
238
+ }
239
+ }
240
+
241
+ private async assembleSnapshot(runtime: SyncRuntime): Promise<SyncSnapshot> {
242
+ const contributors = runtime.getContributors();
243
+ const createdAt = nowIso();
244
+ const payloads: SyncSnapshot["contributors"] = {};
245
+ for (const entry of contributors) {
246
+ const payload = await entry.contributor.collect({ state: runtime.state });
247
+ payloads[entry.contributor.id] = {
248
+ schemaVersion: entry.contributor.schemaVersion,
249
+ updatedAt: createdAt,
250
+ payload,
251
+ };
252
+ }
253
+ return {
254
+ schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
255
+ appId: "gloomberb",
256
+ clientId: this.clientId ??= resolveClientId(),
257
+ createdAt,
258
+ contributors: payloads,
259
+ };
260
+ }
261
+
262
+ private updateAvailability(): void {
263
+ const transport = this.runtime?.getTransport() ?? null;
264
+ if (!transport) {
265
+ this.setStatus({ phase: "disabled", transportId: null, error: null });
266
+ return;
267
+ }
268
+ if (!transport.transport.isAvailable()) {
269
+ this.setStatus({ phase: "disabled", transportId: transport.transport.id, error: null });
270
+ return;
271
+ }
272
+ if (
273
+ this.status.transportId === transport.transport.id &&
274
+ this.status.phase !== "disabled" &&
275
+ this.status.phase !== "idle"
276
+ ) {
277
+ return;
278
+ }
279
+ this.setStatus({
280
+ phase: "idle",
281
+ transportId: transport.transport.id,
282
+ error: null,
283
+ });
284
+ }
285
+
286
+ private setStatus(patch: Partial<CloudSyncStatus>): void {
287
+ this.status = { ...this.status, ...patch };
288
+ this.emit();
289
+ }
290
+
291
+ private emit(): void {
292
+ for (const listener of this.listeners) listener();
293
+ }
294
+ }
295
+
296
+ export const cloudSyncController = new CloudSyncController();
@@ -0,0 +1,251 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createInitialState } from "../core/state/app/state";
3
+ import { createDefaultConfig } from "../types/config";
4
+ import type { PricePoint } from "../types/financials";
5
+ import type { TickerRecord } from "../types/ticker";
6
+ import {
7
+ __syncContributorInternalsForTests,
8
+ coreCollectionsSyncContributor,
9
+ coreConfigSyncContributor,
10
+ } from "./core-contributors";
11
+ import { setSyncedProfileAnalytics } from "./profile-analytics";
12
+
13
+ describe("core sync contributors", () => {
14
+ function priceHistoryFromReturns(returns: number[]): PricePoint[] {
15
+ let close = 100;
16
+ return [
17
+ { date: new Date("2026-06-01T20:00:00.000Z"), close },
18
+ ...returns.map((value, index) => {
19
+ close *= 1 + value;
20
+ return {
21
+ date: new Date(Date.UTC(2026, 5, index + 2, 20)),
22
+ close,
23
+ };
24
+ }),
25
+ ];
26
+ }
27
+
28
+ test("redacts local paths and credential-like config keys", async () => {
29
+ const config = createDefaultConfig("/Users/vince/private-data");
30
+ config.brokerInstances = [{
31
+ id: "broker-1",
32
+ brokerType: "demo",
33
+ label: "Demo Broker",
34
+ config: {
35
+ apiKey: "secret-api-key",
36
+ password: "secret-password",
37
+ },
38
+ }];
39
+ config.pluginConfig = {
40
+ "demo-plugin": {
41
+ theme: "dark",
42
+ token: "secret-token",
43
+ downloadPath: "/Users/vince/private-downloads",
44
+ },
45
+ };
46
+
47
+ const state = createInitialState(config);
48
+ const payload = await coreConfigSyncContributor.collect({ state });
49
+ const serialized = JSON.stringify(payload);
50
+
51
+ expect(serialized).not.toContain("/Users/vince/private-data");
52
+ expect(serialized).not.toContain("/Users/vince/private-downloads");
53
+ expect(serialized).not.toContain("secret-api-key");
54
+ expect(serialized).not.toContain("secret-password");
55
+ expect(serialized).not.toContain("secret-token");
56
+ expect(serialized).toContain("Demo Broker");
57
+ });
58
+
59
+ test("syncs collection memberships and sanitized positions", async () => {
60
+ const config = createDefaultConfig("/tmp/gloomberb-sync-test");
61
+ config.portfolios = [{
62
+ id: "main",
63
+ name: "Main",
64
+ currency: "USD",
65
+ brokerAccountId: "account-id",
66
+ brokerInstanceId: "broker-id",
67
+ }];
68
+ config.watchlists = [{ id: "ai", name: "AI" }];
69
+ const ticker: TickerRecord = {
70
+ metadata: {
71
+ ticker: "NVDA",
72
+ exchange: "NASDAQ",
73
+ currency: "USD",
74
+ name: "NVIDIA",
75
+ portfolios: ["main"],
76
+ watchlists: ["ai"],
77
+ positions: [{
78
+ portfolio: "main",
79
+ shares: 10,
80
+ avgCost: 100,
81
+ broker: "manual",
82
+ marketValue: 1500,
83
+ brokerAccountId: "account-id",
84
+ brokerInstanceId: "broker-id",
85
+ brokerContractId: 42,
86
+ }],
87
+ custom: { secretToken: "hidden", note: "keep" },
88
+ tags: ["semis"],
89
+ },
90
+ };
91
+ const state = createInitialState(config);
92
+ state.tickers = new Map([["NVDA", ticker]]);
93
+ state.financials = new Map([[
94
+ "NVDA",
95
+ {
96
+ quote: {
97
+ symbol: "NVDA",
98
+ price: 150,
99
+ currency: "USD",
100
+ change: 1,
101
+ changePercent: 2,
102
+ lastUpdated: 1,
103
+ },
104
+ fundamentals: { return1Y: 0.42 },
105
+ annualStatements: [],
106
+ quarterlyStatements: [],
107
+ priceHistory: [
108
+ { date: new Date("2026-06-23T20:00:00.000Z"), close: 125 },
109
+ { date: new Date("2026-06-30T20:00:00.000Z"), close: 149 },
110
+ ],
111
+ },
112
+ ]]);
113
+
114
+ const payload = await coreCollectionsSyncContributor.collect({ state });
115
+ const serialized = JSON.stringify(payload);
116
+
117
+ expect(serialized).toContain("NVDA");
118
+ expect(serialized).toContain("AI");
119
+ expect(serialized).toContain("Main");
120
+ expect(serialized).toContain("keep");
121
+ expect(serialized).not.toContain("account-id");
122
+ expect(serialized).not.toContain("broker-id");
123
+ expect(serialized).not.toContain("hidden");
124
+ expect(serialized).not.toContain("brokerContractId");
125
+ expect((payload as any).baseCurrency).toBe("USD");
126
+ expect((payload as any).exchangeRates).toEqual({ USD: 1 });
127
+ expect((payload as any).tickers[0].quote.price).toBe(150);
128
+ expect((payload as any).tickers[0].quote.weekReferencePrice).toBe(125);
129
+ expect((payload as any).tickers[0].quote.weekChangePercent).toBe(20);
130
+ expect((payload as any).analyticsByPortfolio.main.oneYearReturn).toBe(0.42);
131
+ });
132
+
133
+ test("syncs only public return and beta analytics", async () => {
134
+ const config = createDefaultConfig("/tmp/gloomberb-sync-test");
135
+ config.baseCurrency = "USD";
136
+ config.portfolios = [
137
+ { id: "main", name: "Main", currency: "USD" },
138
+ {
139
+ id: "broker:ibkr:U123",
140
+ name: "U123",
141
+ currency: "USD",
142
+ brokerId: "ibkr",
143
+ brokerInstanceId: "ibkr",
144
+ brokerAccountId: "U123",
145
+ },
146
+ ];
147
+ config.brokerInstances = [{
148
+ id: "ibkr",
149
+ brokerType: "ibkr",
150
+ label: "IBKR",
151
+ config: {},
152
+ enabled: true,
153
+ }];
154
+ const ticker = (symbol: string, portfolio: string): TickerRecord => ({
155
+ metadata: {
156
+ ticker: symbol,
157
+ exchange: "TSE",
158
+ currency: "JPY",
159
+ name: symbol,
160
+ portfolios: [portfolio],
161
+ watchlists: [],
162
+ positions: [{
163
+ portfolio,
164
+ shares: 10,
165
+ avgCost: 900,
166
+ broker: "manual",
167
+ currency: "JPY",
168
+ }],
169
+ custom: {},
170
+ tags: [],
171
+ },
172
+ });
173
+ const state = createInitialState(config);
174
+ state.exchangeRates = new Map([["USD", 1], ["JPY", 0.0067]]);
175
+ state.tickers = new Map([
176
+ ["7203.T", ticker("7203.T", "main")],
177
+ ["6758.T", ticker("6758.T", "broker:ibkr:U123")],
178
+ ]);
179
+ state.financials = new Map([
180
+ ["7203.T", {
181
+ quote: { symbol: "7203.T", price: 1000, currency: "JPY", change: 0, changePercent: 0, lastUpdated: 1 },
182
+ fundamentals: { return1Y: 0.1 },
183
+ priceHistory: priceHistoryFromReturns([0.015, -0.0045, 0.018, 0.009, -0.006, 0.012, 0.0045, -0.003, 0.0105, 0.006, -0.0015]),
184
+ annualStatements: [],
185
+ quarterlyStatements: [],
186
+ }],
187
+ ["6758.T", {
188
+ quote: { symbol: "6758.T", price: 1000, currency: "JPY", change: 0, changePercent: 0, lastUpdated: 1 },
189
+ fundamentals: { return1Y: 0.2 },
190
+ priceHistory: priceHistoryFromReturns([0.03, -0.009, 0.036, 0.018, -0.012, 0.024, 0.009, -0.006, 0.021, 0.012, -0.003]),
191
+ annualStatements: [],
192
+ quarterlyStatements: [],
193
+ }],
194
+ ["SPY", {
195
+ quote: { symbol: "SPY", price: 100, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1 },
196
+ priceHistory: priceHistoryFromReturns([0.01, -0.003, 0.012, 0.006, -0.004, 0.008, 0.003, -0.002, 0.007, 0.004, -0.001]),
197
+ annualStatements: [],
198
+ quarterlyStatements: [],
199
+ }],
200
+ ]);
201
+ state.brokerAccounts = {
202
+ ibkr: [{
203
+ accountId: "U123",
204
+ name: "U123",
205
+ currency: "USD",
206
+ source: "flex",
207
+ grossPositionValue: 1234,
208
+ }],
209
+ };
210
+
211
+ const payload = await coreCollectionsSyncContributor.collect({ state }) as any;
212
+
213
+ expect(payload.baseCurrency).toBe("USD");
214
+ expect(payload.exchangeRates).toEqual({ USD: 1, JPY: 0.0067 });
215
+ expect(payload.analyticsByPortfolio.main.oneYearReturn).toBe(0.1);
216
+ expect(payload.analyticsByPortfolio.main.spyBeta).toBeCloseTo(1.5, 5);
217
+ expect(payload.analyticsByPortfolio["broker:ibkr:U123"].oneYearReturn).toBe(0.2);
218
+ expect(payload.analyticsByPortfolio["broker:ibkr:U123"].spyBeta).toBeCloseTo(3, 5);
219
+ expect(payload.analyticsByPortfolio.main).not.toHaveProperty("marketValue");
220
+ expect(payload.analyticsByPortfolio.main).not.toHaveProperty("holdingsCount");
221
+ expect(payload.analyticsByPortfolio.main).not.toHaveProperty("currency");
222
+ expect(payload.analyticsByPortfolio.main).not.toHaveProperty("sourceLabel");
223
+ });
224
+
225
+ test("redaction removes nested credential-shaped fields", () => {
226
+ const sanitized = __syncContributorInternalsForTests.sanitizeUnknown({
227
+ nested: {
228
+ refreshToken: "nope",
229
+ publicValue: "ok",
230
+ },
231
+ });
232
+
233
+ expect(sanitized).toEqual({ nested: { publicValue: "ok" } });
234
+ });
235
+
236
+ test("uses preview-computed profile analytics without exposing values", async () => {
237
+ const config = createDefaultConfig("/tmp/gloomberb-sync-test");
238
+ config.portfolios = [{ id: "preview", name: "Preview", currency: "USD" }];
239
+ const state = createInitialState(config);
240
+
241
+ setSyncedProfileAnalytics("preview", { oneYearReturn: 0.25, spyBeta: 1.4 });
242
+ const payload = await coreCollectionsSyncContributor.collect({ state }) as any;
243
+ setSyncedProfileAnalytics("preview", null);
244
+
245
+ expect(payload.analyticsByPortfolio.preview).toEqual({
246
+ oneYearReturn: 0.25,
247
+ spyBeta: 1.4,
248
+ });
249
+ expect(payload.analyticsByPortfolio.preview).not.toHaveProperty("marketValue");
250
+ });
251
+ });