gloomberb 0.12.0 → 0.13.2

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 (219) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.test.ts +34 -0
  5. package/src/api-client/index.ts +10 -2
  6. package/src/api-client/request.ts +4 -1
  7. package/src/api-client/research-activity.ts +61 -0
  8. package/src/api-client/types.ts +2 -1
  9. package/src/cli/commands/plugins.ts +4 -3
  10. package/src/cli/desktop-pane-shot.ts +79 -10
  11. package/src/cli/pane-functions/capabilities.ts +76 -23
  12. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  13. package/src/cli/pane-functions/cloud-session.ts +38 -0
  14. package/src/cli/pane-functions/dom.test.ts +41 -0
  15. package/src/cli/pane-functions/dom.ts +199 -0
  16. package/src/cli/pane-functions/index.test.ts +13 -2
  17. package/src/cli/pane-functions/index.ts +9 -5
  18. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  19. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  20. package/src/cli/pane-functions/report.test.ts +29 -1
  21. package/src/cli/pane-functions/report.ts +68 -7
  22. package/src/cli/pane-functions/screenshot.ts +195 -42
  23. package/src/cli/restore-plugins.ts +10 -3
  24. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  25. package/src/components/chart/composite/composite-chart.tsx +87 -21
  26. package/src/components/chart/composite/rasterizer.ts +33 -0
  27. package/src/components/chart/composite/renderers.test.ts +51 -4
  28. package/src/components/chart/composite/scene.test.ts +128 -2
  29. package/src/components/chart/composite/scene.ts +92 -16
  30. package/src/components/chart/composite/text-renderer.ts +13 -0
  31. package/src/components/chart/composite/time-scale.ts +30 -10
  32. package/src/components/chart/composite/types.ts +26 -0
  33. package/src/components/command-bar/assist/runtime.ts +38 -30
  34. package/src/components/command-bar/surface/assist.test.tsx +63 -9
  35. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  36. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  37. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  38. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  39. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  40. package/src/data/config/store/index.test.ts +23 -0
  41. package/src/data/config/store/node.ts +7 -1
  42. package/src/data/config/store/normalize.ts +4 -0
  43. package/src/news/types.ts +1 -0
  44. package/src/plugins/builtin/cds/client.test.ts +20 -0
  45. package/src/plugins/builtin/cds/client.ts +13 -3
  46. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  47. package/src/plugins/builtin/cds/headless.ts +113 -0
  48. package/src/plugins/builtin/cds/index.tsx +4 -0
  49. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  50. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  51. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  52. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  53. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  54. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  55. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  56. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  57. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  58. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  59. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  60. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  61. package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
  62. package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
  63. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  64. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  65. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  66. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  67. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  68. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  69. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  70. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  71. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  72. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  73. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  74. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  75. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  76. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  77. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  78. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  79. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  80. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  81. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  82. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  83. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  84. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  85. package/src/plugins/builtin/earnings/headless.ts +162 -0
  86. package/src/plugins/builtin/earnings/index.tsx +2 -0
  87. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  88. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  89. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  90. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  91. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  93. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
  94. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  95. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  96. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  97. package/src/plugins/builtin/holders/client.ts +29 -0
  98. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  99. package/src/plugins/builtin/holders/headless.ts +162 -0
  100. package/src/plugins/builtin/holders/index.tsx +14 -8
  101. package/src/plugins/builtin/holders/pane.tsx +7 -1
  102. package/src/plugins/builtin/insider/client.ts +50 -0
  103. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  104. package/src/plugins/builtin/insider/headless.ts +117 -0
  105. package/src/plugins/builtin/insider/index.tsx +21 -50
  106. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  107. package/src/plugins/builtin/insider/model.ts +72 -0
  108. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  109. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  110. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  111. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  112. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  113. package/src/plugins/builtin/market-movers/client.ts +76 -0
  114. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  115. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  116. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  117. package/src/plugins/builtin/news/headless.test.ts +138 -0
  118. package/src/plugins/builtin/news/headless.ts +190 -0
  119. package/src/plugins/builtin/news/index.tsx +13 -9
  120. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  121. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  122. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  123. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  124. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  125. package/src/plugins/builtin/research/client.ts +12 -0
  126. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  127. package/src/plugins/builtin/research/event-model.ts +245 -0
  128. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  129. package/src/plugins/builtin/research/events-headless.ts +87 -0
  130. package/src/plugins/builtin/research/headless.test.ts +93 -0
  131. package/src/plugins/builtin/research/headless.ts +161 -0
  132. package/src/plugins/builtin/research/index.tsx +32 -18
  133. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  134. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  135. package/src/plugins/builtin/scanner/headless.ts +125 -0
  136. package/src/plugins/builtin/scanner/index.ts +4 -0
  137. package/src/plugins/builtin/sec/client.ts +16 -0
  138. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  139. package/src/plugins/builtin/sec/headless.ts +69 -0
  140. package/src/plugins/builtin/sec/index.tsx +20 -75
  141. package/src/plugins/builtin/sec/model.ts +89 -0
  142. package/src/plugins/builtin/sectors/client.ts +54 -0
  143. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  144. package/src/plugins/builtin/sectors/headless.ts +80 -0
  145. package/src/plugins/builtin/sectors/index.tsx +6 -51
  146. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  147. package/src/plugins/builtin/short-interest/client.ts +5 -2
  148. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  149. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  150. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  151. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  152. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  153. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  154. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  155. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  156. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  157. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  158. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  159. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  160. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  161. package/src/plugins/builtin/volatility/client.ts +16 -3
  162. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  163. package/src/plugins/builtin/volatility/headless.ts +75 -0
  164. package/src/plugins/builtin/volatility/index.tsx +4 -0
  165. package/src/plugins/builtin/world-indices/client.ts +43 -0
  166. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  167. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  168. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  169. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  170. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  171. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  172. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  173. package/src/plugins/bundle.test.ts +58 -1
  174. package/src/plugins/bundle.ts +4 -2
  175. package/src/plugins/catalog-backend.test.ts +26 -0
  176. package/src/plugins/catalog-backend.ts +9 -2
  177. package/src/plugins/catalog-browser.test.ts +0 -13
  178. package/src/plugins/catalog-browser.ts +4 -0
  179. package/src/plugins/catalog-manifest.test.ts +15 -0
  180. package/src/plugins/catalog-manifest.ts +12 -5
  181. package/src/plugins/catalog-ui.ts +0 -6
  182. package/src/plugins/host-contract.ts +9 -0
  183. package/src/plugins/host-modules.ts +12 -0
  184. package/src/plugins/loader.ts +63 -15
  185. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  186. package/src/plugins/prediction-markets/headless.ts +255 -0
  187. package/src/plugins/prediction-markets/index.tsx +2 -0
  188. package/src/plugins/seed.ts +3 -0
  189. package/src/public/broker.ts +6 -0
  190. package/src/remote/schema.test.ts +65 -0
  191. package/src/remote/schema.ts +322 -39
  192. package/src/remote/types.ts +49 -2
  193. package/src/renderers/browser/app-services.ts +3 -0
  194. package/src/renderers/browser/config-host.ts +24 -8
  195. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  196. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  197. package/src/renderers/browser/main.tsx +3 -1
  198. package/src/renderers/browser/research-entry.test.ts +8 -0
  199. package/src/renderers/browser/research-entry.ts +7 -0
  200. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  201. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  202. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  203. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  204. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  205. package/src/sources/gloomberb-cloud/news.ts +1 -0
  206. package/src/sync/core-contributors.test.ts +111 -0
  207. package/src/sync/core-contributors.ts +155 -1
  208. package/src/time-series/resolve.test.ts +118 -1
  209. package/src/time-series/resolve.ts +14 -16
  210. package/src/types/config.ts +2 -0
  211. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  212. package/src/plugins/broker-sync/native-loader.ts +0 -10
  213. package/src/plugins/broker-sync/normalize.ts +0 -308
  214. package/src/plugins/broker-sync/public.ts +0 -122
  215. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  216. package/src/plugins/broker-sync/robinhood.ts +0 -114
  217. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  218. package/src/plugins/broker-sync/simplefin.ts +0 -70
  219. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
  import { createAppServices, type AppServices } from "../../../../core/app-services";
5
- import { getDesktopBackendPlugins } from "../../../../plugins/catalog-backend";
5
+ import { loadDesktopBackendPlugins } from "../../../../plugins/catalog-backend";
6
+ import { restoreExtractedPlugins } from "../../../../cli/restore-plugins";
6
7
  import type { AppSessionSnapshot } from "../../../../core/state/session-persistence";
7
8
  import {
8
9
  getDataDir,
@@ -133,13 +134,19 @@ export async function initializeDesktopBackend<TRpc>(
133
134
  });
134
135
  }
135
136
 
136
- options.setCurrentConfig(await initDataDir(await resolveDesktopDataDir()));
137
+ const initialConfig = await initDataDir(await resolveDesktopDataDir());
138
+ // Restore plugins that moved into their own repositories before the catalog
139
+ // is read, the same as the terminal does at startup. Without it a
140
+ // desktop-only user silently loses a feature the day it is extracted, since
141
+ // nothing else installs it for them.
142
+ const seededPlugins = await restoreExtractedPlugins();
143
+ options.setCurrentConfig(seededPlugins ? { ...initialConfig, seededPlugins } : initialConfig);
137
144
  const config = options.getCurrentConfig();
138
145
  if (!config) throw new Error("Desktop config failed to initialize.");
139
146
 
140
147
  const services = createAppServices({
141
148
  config,
142
- plugins: getDesktopBackendPlugins(),
149
+ plugins: await loadDesktopBackendPlugins(),
143
150
  });
144
151
  options.setServices(services);
145
152
  await services.ready;
@@ -1,11 +1,11 @@
1
- import { readdir, stat } from "fs/promises";
1
+ import { readdir, rm, stat } from "fs/promises";
2
2
  import { existsSync } from "fs";
3
3
  import { join } from "path";
4
4
 
5
5
  import type { DesktopExternalPluginBundle } from "../shared/protocol";
6
6
  import { bundleExternalPlugin, pluginBundleCacheDir } from "../../../plugins/bundle";
7
7
  import { linkHostPackages } from "../../../plugins/host-link";
8
- import { getPluginsDir, resolvePluginEntry } from "../../../plugins/loader";
8
+ import { getPluginCacheDir, getPluginsDir, isPluginDirectory, resolvePluginEntry } from "../../../plugins/loader";
9
9
  import type { GloomPlugin } from "../../../types/plugin";
10
10
  import { debugLog } from "../../../utils/debug-log";
11
11
 
@@ -60,15 +60,31 @@ async function readPluginMetadata(entryFile: string): Promise<GloomPlugin | null
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * Bundles used to be cached inside the plugins folder, where the CLI listed
65
+ * `.cache` as an installed plugin and tried to `git pull` it. It is only a
66
+ * cache, so the old copy is dropped rather than migrated.
67
+ */
68
+ async function removeLegacyBundleCache(pluginsDir: string): Promise<void> {
69
+ const legacy = join(pluginsDir, ".cache");
70
+ if (!existsSync(legacy)) return;
71
+ try {
72
+ await rm(legacy, { recursive: true, force: true });
73
+ } catch (error) {
74
+ log.error(`Could not remove the legacy bundle cache: ${error}`);
75
+ }
76
+ }
77
+
63
78
  export async function collectExternalPluginBundles(): Promise<DesktopExternalPluginBundle[]> {
64
79
  const pluginsDir = getPluginsDir();
65
80
  if (!existsSync(pluginsDir)) return [];
66
81
 
67
- const outDir = pluginBundleCacheDir(join(pluginsDir, ".cache"));
82
+ await removeLegacyBundleCache(pluginsDir);
83
+ const outDir = pluginBundleCacheDir(getPluginCacheDir());
68
84
  const bundles: DesktopExternalPluginBundle[] = [];
69
85
 
70
86
  for (const entry of await readdir(pluginsDir, { withFileTypes: true })) {
71
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
87
+ if (!entry.isDirectory() || !isPluginDirectory(entry.name)) continue;
72
88
  const pluginDir = join(pluginsDir, entry.name);
73
89
 
74
90
  const entryFile = await resolvePluginEntry(pluginDir);
@@ -23,8 +23,6 @@ const COMMON_ALIAS_RULES: AliasRule[] = [
23
23
  ["./kitty/support", "components/chart/native/renderer-selection.ts", "native-stubs/chart/kitty-support.ts"],
24
24
  ["native/surface/manager", "native-stubs/chart/surface-manager.ts"],
25
25
  ["native/surface/sync", "native-stubs/chart/surface-sync.ts"],
26
- ["./native-loader", "plugins/broker-sync/robinhood.ts", "native-stubs/broker-sync-native-loader.ts"],
27
- ["./native-loader", "plugins/broker-sync/simplefin.ts", "native-stubs/broker-sync-native-loader.ts"],
28
26
  ];
29
27
 
30
28
  export function electrobunViewPath(...parts: string[]): string {
@@ -26,6 +26,7 @@ import {
26
26
  getPresetResolution,
27
27
  normalizeChartResolutionSupport,
28
28
  TIME_RANGE_ORDER,
29
+ type ManualChartResolution,
29
30
  } from "../../../time-series/resolution";
30
31
  import type { TimeRange } from "../../../components/chart/core/types";
31
32
  import type { AppConfig } from "../../../types/config";
@@ -35,7 +36,7 @@ import type {
35
36
  } from "../../../core/app-service-ports";
36
37
  import type { CachedResourceRecord, ResourceCacheKey, SetResourceOptions } from "../../../data/resource-store";
37
38
  import type { CachedFinancialsTarget, DataProvider, QuoteSubscriptionTarget } from "../../../types/data-provider";
38
- import type { OptionsChain, TickerFinancials } from "../../../types/financials";
39
+ import type { OptionsChain, PricePoint, TickerFinancials } from "../../../types/financials";
39
40
  import type { TickerRecord } from "../../../types/ticker";
40
41
  import type { AppState, PaneRuntimeState } from "../../../core/state/app/state";
41
42
  import type { PaneDef } from "../../../types/plugin";
@@ -50,6 +51,19 @@ import { chartSeriesSourceKey } from "../../../capabilities";
50
51
  import { apiClient, setCloudApiFetchTransport } from "../../../api-client";
51
52
  import { createGloomberbCloudProvider } from "../../../sources/gloomberb-cloud";
52
53
 
54
+ interface CliPaneShotIntradayHistory {
55
+ symbol: string;
56
+ exchange: string;
57
+ rangePreset: "1D" | "1W";
58
+ resolution: ManualChartResolution;
59
+ requestedSession: string | null;
60
+ sessionDates: string[];
61
+ points: PricePoint[];
62
+ start: string | null;
63
+ end: string | null;
64
+ unavailableReason: string | null;
65
+ }
66
+
53
67
  interface CliPaneShotPayload {
54
68
  config: AppConfig;
55
69
  paneId: string;
@@ -57,6 +71,7 @@ interface CliPaneShotPayload {
57
71
  heightCells: number;
58
72
  tickers: TickerRecord[];
59
73
  financials: Array<[string, TickerFinancials]>;
74
+ intradayHistories: CliPaneShotIntradayHistory[];
60
75
  optionsChains: Array<[string, OptionsChain]>;
61
76
  fredSeries: Array<[string, FredSeriesCacheEntry]>;
62
77
  valuationSeries: Array<[string, DatedObservation[]]>;
@@ -187,8 +202,11 @@ function isShotLoadingTextVisible(): boolean {
187
202
  * price presets passed.
188
203
  */
189
204
  function revivePayloadDates(payload: CliPaneShotPayload): void {
190
- for (const [, data] of payload.financials) {
191
- const history = data.priceHistory;
205
+ const histories = [
206
+ ...payload.financials.map(([, data]) => data.priceHistory),
207
+ ...(payload.intradayHistories ?? []).map((entry) => entry.points),
208
+ ];
209
+ for (const history of histories) {
192
210
  if (!Array.isArray(history)) continue;
193
211
  for (const point of history) {
194
212
  if (!(point.date instanceof Date)) point.date = new Date(point.date as unknown as string);
@@ -266,6 +284,16 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
266
284
  financials.set(canonicalTickerKey(instrument.symbol, instrument.exchange), data);
267
285
  if (!instrument.exchange) financials.set(normalizeSymbol(instrument.symbol), data);
268
286
  }
287
+ const intradayHistories = new Map<string, CliPaneShotIntradayHistory>();
288
+ for (const entry of payload.intradayHistories ?? []) {
289
+ intradayHistories.set(canonicalTickerKey(entry.symbol, entry.exchange), entry);
290
+ intradayHistories.set(normalizeSymbol(entry.symbol), entry);
291
+ }
292
+ const intradayHistory = (symbol: string, exchange?: string) => (
293
+ intradayHistories.get(canonicalTickerKey(symbol, exchange))
294
+ ?? intradayHistories.get(normalizeSymbol(symbol))
295
+ );
296
+
269
297
  const optionsChains = new Map<string, OptionsChain>();
270
298
  for (const [key, data] of payload.optionsChains ?? []) {
271
299
  const instrument = parsePublicTickerKey(key);
@@ -345,14 +373,39 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
345
373
  return resolveShotWork(null);
346
374
  },
347
375
  getPriceHistory(ticker, exchange, range) {
348
- return trackShotWork(Promise.resolve().then(() => (
349
- clipPriceHistoryToRange(getFinancials(ticker, exchange).priceHistory ?? [], range)
350
- )));
376
+ return trackShotWork(Promise.resolve().then(() => {
377
+ const intraday = intradayHistory(ticker, exchange);
378
+ if (intraday?.unavailableReason) throw new Error(intraday.unavailableReason);
379
+ return clipPriceHistoryToRange(
380
+ intraday ? intraday.points : getFinancials(ticker, exchange).priceHistory ?? [],
381
+ range,
382
+ );
383
+ }));
384
+ },
385
+ getPriceHistoryForResolution(ticker, exchange, bufferRange, resolution) {
386
+ return trackShotWork(Promise.resolve().then(() => {
387
+ const intraday = intradayHistory(ticker, exchange);
388
+ if (intraday?.unavailableReason) throw new Error(intraday.unavailableReason);
389
+ if (intraday && intraday.resolution !== resolution) return [];
390
+ return clipPriceHistoryToRange(
391
+ intraday ? intraday.points : getFinancials(ticker, exchange).priceHistory ?? [],
392
+ bufferRange,
393
+ );
394
+ }));
351
395
  },
352
- getPriceHistoryForResolution(ticker, exchange, bufferRange) {
353
- return trackShotWork(Promise.resolve().then(() => (
354
- clipPriceHistoryToRange(getFinancials(ticker, exchange).priceHistory ?? [], bufferRange)
355
- )));
396
+ getDetailedPriceHistory(ticker, exchange, startDate, endDate, resolution) {
397
+ return trackShotWork(Promise.resolve().then(() => {
398
+ const intraday = intradayHistory(ticker, exchange);
399
+ if (intraday?.unavailableReason) throw new Error(intraday.unavailableReason);
400
+ if (intraday && intraday.resolution !== resolution) return [];
401
+ const start = startDate.getTime();
402
+ const end = endDate.getTime();
403
+ const points = intraday?.points ?? getFinancials(ticker, exchange).priceHistory ?? [];
404
+ return points.filter((point) => {
405
+ const timestamp = point.date.getTime();
406
+ return timestamp >= start && timestamp < end;
407
+ });
408
+ }));
356
409
  },
357
410
  getChartResolutionSupport() {
358
411
  return resolveShotWork(SHOT_CHART_RESOLUTION_SUPPORT);
@@ -85,6 +85,7 @@ export function mapCloudNewsArticle(
85
85
  importance: scores.importance,
86
86
  isBreaking: !!item.flags?.breaking,
87
87
  isDeveloping: !!item.flags?.developing,
88
+ sourceCount: item.sourceCount,
88
89
  items: item.items?.map(mapCloudNewsStoryItem) ?? [],
89
90
  };
90
91
  }
@@ -129,6 +129,99 @@ describe("core sync contributors", () => {
129
129
  }
130
130
  });
131
131
 
132
+ test("preserves local broker identity when applying sanitized portfolios", () => {
133
+ const config = createDefaultConfig("/tmp/gloomberb-sync-broker-identity-test");
134
+ config.portfolios = [{
135
+ id: "broker:demo-live:ACCOUNT-1",
136
+ name: "Primary",
137
+ currency: "USD",
138
+ brokerId: "demo",
139
+ brokerInstanceId: "demo-live",
140
+ brokerAccountId: "ACCOUNT-1",
141
+ lastSyncedAt: 100,
142
+ }];
143
+
144
+ const collected = __syncContributorInternalsForTests.collectCoreConfigPayload(config) as {
145
+ portfolios: Array<Record<string, unknown>>;
146
+ };
147
+ expect(collected.portfolios[0]).not.toHaveProperty("brokerInstanceId");
148
+ expect(collected.portfolios[0]).not.toHaveProperty("brokerAccountId");
149
+
150
+ const merged = __syncContributorInternalsForTests.mergeConfigPayload(config, {
151
+ portfolios: [{
152
+ id: "broker:demo-live:ACCOUNT-1",
153
+ name: "Primary",
154
+ currency: "USD",
155
+ brokerId: "demo",
156
+ lastSyncedAt: 200,
157
+ }],
158
+ });
159
+
160
+ expect(merged?.portfolios).toEqual([{
161
+ id: "broker:demo-live:ACCOUNT-1",
162
+ name: "Primary",
163
+ currency: "USD",
164
+ brokerId: "demo",
165
+ brokerInstanceId: "demo-live",
166
+ brokerAccountId: "ACCOUNT-1",
167
+ lastSyncedAt: 200,
168
+ }]);
169
+ });
170
+
171
+ test("does not reintroduce older unlinked broker portfolios from cloud", () => {
172
+ const config = createDefaultConfig("/tmp/gloomberb-sync-broker-identity-test");
173
+ config.portfolios = [
174
+ {
175
+ id: "main",
176
+ name: "Main",
177
+ currency: "USD",
178
+ },
179
+ {
180
+ id: "broker:demo-live:ACCOUNT-1",
181
+ name: "Primary",
182
+ currency: "USD",
183
+ brokerId: "demo",
184
+ brokerInstanceId: "demo-live",
185
+ brokerAccountId: "ACCOUNT-1",
186
+ lastSyncedAt: 200,
187
+ },
188
+ {
189
+ id: "broker:demo-live:ACCOUNT-2",
190
+ name: "Secondary",
191
+ currency: "USD",
192
+ brokerId: "demo",
193
+ brokerInstanceId: "demo-live",
194
+ brokerAccountId: "ACCOUNT-2",
195
+ lastSyncedAt: 200,
196
+ },
197
+ ];
198
+
199
+ const merged = __syncContributorInternalsForTests.mergeConfigPayload(config, {
200
+ portfolios: [
201
+ { id: "main", name: "Main", currency: "USD" },
202
+ { id: "broker:demo-live:ACCOUNT-1", name: "Primary", currency: "USD", brokerId: "demo", lastSyncedAt: 200 },
203
+ { id: "broker:demo-live:ACCOUNT-2", name: "Secondary", currency: "USD", brokerId: "demo", lastSyncedAt: 200 },
204
+ { id: "broker:demo-old:ACCOUNT-1", name: "Primary", currency: "USD", brokerId: "demo", lastSyncedAt: 100 },
205
+ { id: "broker:demo-old:ACCOUNT-2", name: "Secondary", currency: "USD", brokerId: "demo", lastSyncedAt: 100 },
206
+ { id: "broker:demo-old:REMOVED", name: "Removed", currency: "USD", brokerId: "demo", lastSyncedAt: 100 },
207
+ { id: "broker:demo-other:ACCOUNT-3", name: "Remote", currency: "USD", brokerId: "demo", lastSyncedAt: 300 },
208
+ ],
209
+ });
210
+
211
+ expect(merged?.portfolios).toEqual([
212
+ config.portfolios[0],
213
+ config.portfolios[1],
214
+ config.portfolios[2],
215
+ {
216
+ id: "broker:demo-other:ACCOUNT-3",
217
+ name: "Remote",
218
+ currency: "USD",
219
+ brokerId: "demo",
220
+ lastSyncedAt: 300,
221
+ },
222
+ ]);
223
+ });
224
+
132
225
  test("keeps resumable onboarding local until the guide is complete", async () => {
133
226
  const config = createDefaultConfig("/tmp/gloomberb-sync-test");
134
227
  config.onboardingComplete = false;
@@ -258,6 +351,24 @@ describe("core sync contributors", () => {
258
351
  expect((payload as any).tickers[0].quote.weekReferencePrice).toBe(125);
259
352
  expect((payload as any).tickers[0].quote.weekChangePercent).toBe(20);
260
353
  expect((payload as any).analyticsByPortfolio.main.oneYearReturn).toBe(0.42);
354
+
355
+ const saved: TickerRecord[] = [];
356
+ const sanitizedTickerPayload = { tickers: (payload as any).tickers };
357
+ await coreCollectionsSyncContributor.apply?.(sanitizedTickerPayload, {
358
+ baselinePayload: sanitizedTickerPayload,
359
+ baselineState: state,
360
+ state,
361
+ getState: () => state,
362
+ isCurrent: () => true,
363
+ dispatch: () => {},
364
+ tickerRepository: { saveTicker: async (record: TickerRecord) => { saved.push(record); } },
365
+ } as unknown as Parameters<NonNullable<typeof coreCollectionsSyncContributor.apply>>[1]);
366
+
367
+ expect(saved[0]?.metadata.positions[0]).toMatchObject({
368
+ brokerInstanceId: "broker-id",
369
+ brokerAccountId: "account-id",
370
+ brokerContractId: 42,
371
+ });
261
372
  });
262
373
 
263
374
  test("keeps a position written while the app was closed", async () => {
@@ -58,6 +58,116 @@ function sanitizePortfolio(portfolio: Portfolio): Portfolio {
58
58
  };
59
59
  }
60
60
 
61
+ function brokerAccountIdFromPortfolioId(portfolioId: string): string | undefined {
62
+ const [kind, instanceId, ...accountParts] = portfolioId.split(":");
63
+ if (kind !== "broker" || !instanceId) return undefined;
64
+ const accountId = accountParts.join(":").trim();
65
+ return accountId && accountId !== "default" ? accountId : undefined;
66
+ }
67
+
68
+ function brokerPortfolioKey(brokerId: string | undefined, accountId: string | undefined): string | undefined {
69
+ if (!brokerId || !accountId) return undefined;
70
+ return [brokerId, accountId].join("\u0000");
71
+ }
72
+
73
+ function portfolioSyncTimestamp(portfolio: Portfolio): number {
74
+ return typeof portfolio.lastSyncedAt === "number" && Number.isFinite(portfolio.lastSyncedAt)
75
+ ? portfolio.lastSyncedAt
76
+ : 0;
77
+ }
78
+
79
+ function latestBrokerSyncByType(portfolios: Portfolio[], linkedOnly: boolean): Map<string, number> {
80
+ const latest = new Map<string, number>();
81
+ for (const portfolio of portfolios) {
82
+ if (!portfolio.brokerId || (linkedOnly && !portfolio.brokerInstanceId)) continue;
83
+ const timestamp = portfolioSyncTimestamp(portfolio);
84
+ latest.set(portfolio.brokerId, Math.max(latest.get(portfolio.brokerId) ?? 0, timestamp));
85
+ }
86
+ return latest;
87
+ }
88
+
89
+ /**
90
+ * Broker identity is deliberately excluded from cloud payloads, but it is
91
+ * still local state that must survive applying a sanitized portfolio back to
92
+ * this device. Match by the stable portfolio ID first, then by the account
93
+ * suffix in that stable ID. A newer local broker snapshot also wins over old
94
+ * unlinked records left in a cloud snapshot from before a reset.
95
+ */
96
+ function mergeLocalBrokerPortfolioIdentity(
97
+ localPortfolios: Portfolio[],
98
+ syncedPortfolios: Portfolio[],
99
+ ): Portfolio[] {
100
+ const localById = new Map(localPortfolios.map((portfolio) => [portfolio.id, portfolio] as const));
101
+ const localByBrokerAccount = new Map<string, Portfolio[]>();
102
+ for (const portfolio of localPortfolios) {
103
+ if (!portfolio.brokerInstanceId) continue;
104
+ const key = brokerPortfolioKey(portfolio.brokerId, portfolio.brokerAccountId);
105
+ if (!key) continue;
106
+ const entries = localByBrokerAccount.get(key) ?? [];
107
+ entries.push(portfolio);
108
+ localByBrokerAccount.set(key, entries);
109
+ }
110
+
111
+ const exactIncomingIds = new Set(
112
+ syncedPortfolios
113
+ .map((portfolio) => portfolio.id)
114
+ .filter((portfolioId) => localById.has(portfolioId)),
115
+ );
116
+ const localLatestByBroker = latestBrokerSyncByType(localPortfolios, true);
117
+ const syncedLatestByBroker = latestBrokerSyncByType(syncedPortfolios, false);
118
+ const merged: Portfolio[] = [];
119
+ const seenIds = new Set<string>();
120
+
121
+ for (const portfolio of syncedPortfolios) {
122
+ const localByStableId = localById.get(portfolio.id);
123
+ const accountId = brokerAccountIdFromPortfolioId(portfolio.id);
124
+ const accountCandidates = localByBrokerAccount.get(brokerPortfolioKey(portfolio.brokerId, accountId) ?? "");
125
+ const local = localByStableId?.brokerInstanceId && localByStableId.brokerId === portfolio.brokerId
126
+ ? localByStableId
127
+ : accountCandidates?.length === 1
128
+ ? accountCandidates[0]
129
+ : undefined;
130
+
131
+ if (local) {
132
+ // Prefer an exact stable-ID entry when an older duplicate appears first
133
+ // in the remote array.
134
+ if (portfolio.id !== local.id && exactIncomingIds.has(local.id)) continue;
135
+ if (seenIds.has(local.id)) continue;
136
+
137
+ const localIsNewer = portfolioSyncTimestamp(local) > portfolioSyncTimestamp(portfolio);
138
+ merged.push(localIsNewer
139
+ ? local
140
+ : {
141
+ ...portfolio,
142
+ id: local.id,
143
+ brokerInstanceId: local.brokerInstanceId,
144
+ brokerAccountId: local.brokerAccountId,
145
+ });
146
+ seenIds.add(local.id);
147
+ continue;
148
+ }
149
+
150
+ const localLatest = portfolio.brokerId ? localLatestByBroker.get(portfolio.brokerId) : undefined;
151
+ if (localLatest !== undefined && localLatest > portfolioSyncTimestamp(portfolio)) continue;
152
+ merged.push(portfolio);
153
+ seenIds.add(portfolio.id);
154
+ }
155
+
156
+ // If the newer local broker snapshot is absent from the cloud array
157
+ // altogether, keep it instead of treating the older array as authoritative.
158
+ for (const portfolio of localPortfolios) {
159
+ if (!portfolio.brokerInstanceId || seenIds.has(portfolio.id)) continue;
160
+ const localLatest = portfolio.brokerId ? localLatestByBroker.get(portfolio.brokerId) : undefined;
161
+ const syncedLatest = portfolio.brokerId ? syncedLatestByBroker.get(portfolio.brokerId) ?? 0 : 0;
162
+ if (localLatest !== undefined && localLatest > syncedLatest) {
163
+ merged.push(portfolio);
164
+ seenIds.add(portfolio.id);
165
+ }
166
+ }
167
+
168
+ return merged;
169
+ }
170
+
61
171
  function sanitizeWatchlist(watchlist: Watchlist): Watchlist {
62
172
  return {
63
173
  id: watchlist.id,
@@ -93,6 +203,38 @@ function sanitizePosition(position: TickerPosition): TickerPosition {
93
203
  };
94
204
  }
95
205
 
206
+ function brokerPositionKey(position: Pick<TickerPosition, "portfolio" | "broker" | "side">): string {
207
+ return [position.portfolio, position.broker, position.side ?? ""].join("\u0000");
208
+ }
209
+
210
+ /** Keep broker position identity local while applying public cloud fields. */
211
+ function mergeLocalBrokerPositionIdentity(
212
+ localPositions: TickerPosition[],
213
+ syncedPositions: TickerPosition[],
214
+ ): TickerPosition[] {
215
+ const localByKey = new Map<string, TickerPosition[]>();
216
+ for (const position of localPositions) {
217
+ if (!position.brokerInstanceId) continue;
218
+ const key = brokerPositionKey(position);
219
+ const entries = localByKey.get(key) ?? [];
220
+ entries.push(position);
221
+ localByKey.set(key, entries);
222
+ }
223
+
224
+ return syncedPositions.map((position) => {
225
+ const entries = localByKey.get(brokerPositionKey(position));
226
+ const local = entries?.shift();
227
+ if (!local) return position;
228
+
229
+ return {
230
+ ...position,
231
+ brokerInstanceId: local.brokerInstanceId,
232
+ brokerAccountId: local.brokerAccountId,
233
+ brokerContractId: local.brokerContractId,
234
+ };
235
+ });
236
+ }
237
+
96
238
  function pricePointTime(point: { date: Date | string | number }): number {
97
239
  const value = point.date;
98
240
  if (value instanceof Date) return value.getTime();
@@ -405,7 +547,12 @@ function mergeConfigPayload(
405
547
 
406
548
  assign("baseCurrency");
407
549
  assign("refreshIntervalMinutes");
408
- assign("portfolios");
550
+ if ("portfolios" in payload && canApply("portfolios")) {
551
+ const syncedPortfolios = payload.portfolios;
552
+ next.portfolios = Array.isArray(syncedPortfolios)
553
+ ? mergeLocalBrokerPortfolioIdentity(config.portfolios, syncedPortfolios as Portfolio[])
554
+ : syncedPortfolios as AppConfig["portfolios"];
555
+ }
409
556
  assign("watchlists");
410
557
  assign("disabledSources");
411
558
  assign("theme");
@@ -532,9 +679,16 @@ export const coreCollectionsSyncContributor: SyncContributor = {
532
679
  // Local edits the cloud has never seen (CLI positions, offline changes)
533
680
  // win here and are uploaded by the push that follows this pull.
534
681
  if (tickerChangedSinceLastSync(current, lastSyncedTickers)) continue;
682
+ const syncedPositions = Array.isArray(rawTicker.positions)
683
+ ? mergeLocalBrokerPositionIdentity(
684
+ current?.metadata.positions ?? [],
685
+ rawTicker.positions as TickerPosition[],
686
+ )
687
+ : current?.metadata.positions ?? [];
535
688
  const metadata = hydrateTickerMetadata({
536
689
  ...current?.metadata,
537
690
  ...rawTicker,
691
+ positions: syncedPositions,
538
692
  broker_contracts: current?.metadata.broker_contracts ?? [],
539
693
  });
540
694
  const record: TickerRecord = { metadata };
@@ -3,7 +3,12 @@ import type { FredSeriesData, FredSeriesLoadResult } from "../data/fred-series";
3
3
  import { createTestDataProvider } from "../test-support/data-provider";
4
4
  import type { TickerFinancials } from "../types/financials";
5
5
  import { CHART_SPEC_VERSION, type ChartSpec } from "./types";
6
- import { ChartResolveCache, mergePriceHistoryWindows, resolveChartSpecData } from "./resolve";
6
+ import {
7
+ ChartResolveCache,
8
+ mergePriceHistoryWindows,
9
+ resolveChartSpecData,
10
+ seedChartResolutionResult,
11
+ } from "./resolve";
7
12
  import { chartQuoteOverrideKeyForSource } from "./live-quotes";
8
13
  import { chartSeriesSourceKey } from "../capabilities";
9
14
  import { buildCustomChartPreset } from "../plugins/builtin/chart-composer/presets";
@@ -26,6 +31,118 @@ const fredLoad = (
26
31
  });
27
32
 
28
33
  describe("resolveChartSpecData", () => {
34
+ test("seeds study series so their panels survive the wait for real data", async () => {
35
+ const date = new Date("2026-01-05T00:00:00.000Z");
36
+ const seeded = seedChartResolutionResult(
37
+ {
38
+ version: CHART_SPEC_VERSION,
39
+ viewport: { range: "1M", resolution: "auto" },
40
+ panels: [{ id: "main" }, { id: "volume" }],
41
+ series: [{
42
+ id: "price",
43
+ source: {
44
+ kind: "security",
45
+ instrument: { symbol: "TEST", exchange: "NASDAQ" },
46
+ fieldId: "market.ohlcv",
47
+ },
48
+ style: "candles",
49
+ transform: "raw",
50
+ axis: "left",
51
+ panelId: "main",
52
+ interpolation: "none",
53
+ }],
54
+ studies: [{
55
+ id: "volume",
56
+ kind: "volume",
57
+ inputSeriesIds: ["price"],
58
+ parameters: {},
59
+ panelId: "volume",
60
+ axis: "left",
61
+ }],
62
+ },
63
+ new Map([[
64
+ chartQuoteOverrideKeyForSource({
65
+ kind: "security",
66
+ instrument: { symbol: "TEST", exchange: "NASDAQ" },
67
+ fieldId: "market.ohlcv",
68
+ }),
69
+ [{ date, open: 10, high: 12, low: 9, close: 11, volume: 5_000 }],
70
+ ]]),
71
+ );
72
+
73
+ const volume = seeded?.series.find((entry) => entry.panelId === "volume");
74
+ expect(volume?.points.map((point) => point.value)).toEqual([5_000]);
75
+ expect(seeded?.bufferedSeries).toBe(seeded?.series);
76
+ });
77
+
78
+ test("keeps study output over the whole buffered history its base series carries", async () => {
79
+ const history = Array.from({ length: 900 }, (_, day) => {
80
+ const date = new Date(Date.UTC(2024, 0, 1) + day * 86_400_000);
81
+ return date.getUTCDay() === 0 || date.getUTCDay() === 6
82
+ ? null
83
+ : { date, open: 100, high: 101, low: 99, close: 100 + (day % 7), volume: 1_000 + day };
84
+ }).filter((point): point is NonNullable<typeof point> => point !== null);
85
+ const provider = createTestDataProvider({
86
+ getTickerFinancials: async () => emptyFinancials(),
87
+ getChartResolutionSupport: () => [{ resolution: "1d", maxRange: "ALL" }],
88
+ getPriceHistoryForResolution: async () => history,
89
+ getDetailedPriceHistory: async () => history,
90
+ });
91
+ const spec: ChartSpec = {
92
+ version: CHART_SPEC_VERSION,
93
+ viewport: { range: "1Y", resolution: "1d" },
94
+ panels: [{ id: "main" }, { id: "volume", label: "Volume", height: 0.24 }],
95
+ series: [{
96
+ id: "price",
97
+ source: {
98
+ kind: "security",
99
+ instrument: { symbol: "TEST", exchange: "NASDAQ" },
100
+ fieldId: "market.close",
101
+ },
102
+ style: "line",
103
+ transform: "raw",
104
+ axis: "left",
105
+ panelId: "main",
106
+ interpolation: "none",
107
+ }],
108
+ studies: [{
109
+ id: "volume",
110
+ kind: "volume",
111
+ inputSeriesIds: ["price"],
112
+ parameters: {},
113
+ panelId: "volume",
114
+ axis: "left",
115
+ }],
116
+ };
117
+ const sources = {
118
+ dataProvider: provider,
119
+ now: new Date("2026-06-15T00:00:00.000Z"),
120
+ loadFredSeries: async () => fredLoad(),
121
+ };
122
+ const cache = new ChartResolveCache();
123
+
124
+ await resolveChartSpecData(spec, sources, cache);
125
+ // Panning back accumulates older bars into the buffer. The study has to
126
+ // follow, or it empties out wherever the base series has already been.
127
+ const panned = await resolveChartSpecData(spec, sources, cache, {
128
+ requestViewport: {
129
+ start: new Date("2024-02-01T00:00:00.000Z"),
130
+ end: new Date("2024-08-01T00:00:00.000Z"),
131
+ },
132
+ });
133
+
134
+ const span = (id: string) => {
135
+ const entry = panned.bufferedSeries?.find((candidate) => candidate.id === id);
136
+ return entry && entry.points.length > 0
137
+ ? {
138
+ first: entry.points[0]!.date.toISOString(),
139
+ last: entry.points.at(-1)!.date.toISOString(),
140
+ }
141
+ : null;
142
+ };
143
+ expect(span("volume")).toEqual(span("price"));
144
+ });
145
+
29
146
  test("routes futures and Treasury aliases through the existing market and FRED pipelines", async () => {
30
147
  const marketRequests: string[] = [];
31
148
  const fredRequests: string[] = [];