gloomberb 0.11.0 → 0.11.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 (392) hide show
  1. package/README.md +38 -15
  2. package/README.zh-CN.md +12 -12
  3. package/package.json +10 -3
  4. package/src/api-client/auth.ts +14 -10
  5. package/src/api-client/data.ts +54 -0
  6. package/src/api-client/index.test.ts +124 -0
  7. package/src/api-client/index.ts +139 -4
  8. package/src/api-client/paths.ts +89 -0
  9. package/src/api-client/request.ts +15 -2
  10. package/src/api-client/socket-health.test.ts +1 -0
  11. package/src/api-client/socket.ts +2 -1
  12. package/src/api-client/types.ts +163 -0
  13. package/src/app/pane-runtime/index.tsx +4 -1
  14. package/src/app/pane-runtime/plugin-bindings.test.ts +117 -0
  15. package/src/app/pane-runtime/plugin-bindings.ts +25 -4
  16. package/src/app/pane-runtime/ticker-inspector-runtime.ts +25 -14
  17. package/src/app/runtime/desktop-deeplink.test.ts +16 -0
  18. package/src/app/runtime/desktop-deeplink.ts +66 -2
  19. package/src/app/runtime/desktop-menu.ts +3 -0
  20. package/src/app/runtime/plugin-bindings.ts +30 -28
  21. package/src/app/runtime/update.ts +8 -6
  22. package/src/app.tsx +21 -7
  23. package/src/architecture/import-boundaries.test.ts +5 -1
  24. package/src/capabilities/factories.ts +4 -0
  25. package/src/cli/commands/automation.test.ts +9 -0
  26. package/src/cli/commands/automation.ts +22 -73
  27. package/src/cli/index.ts +10 -6
  28. package/src/cli/registry.ts +1 -1
  29. package/src/components/chart/composite/composite-chart.test.tsx +45 -2
  30. package/src/components/chart/composite/composite-chart.tsx +16 -12
  31. package/src/components/chart/composite/format.test.ts +34 -0
  32. package/src/components/chart/composite/format.ts +33 -10
  33. package/src/components/chart/composite/scene.ts +0 -21
  34. package/src/components/chart/composite/tools.ts +49 -0
  35. package/src/components/chart/core/axis-format.ts +13 -3
  36. package/src/components/chart/core/index.test.ts +7 -0
  37. package/src/components/chart/core/renderer.ts +3 -4
  38. package/src/components/command-bar/commands/direct/index.ts +4 -0
  39. package/src/components/command-bar/commands/registry.ts +8 -1
  40. package/src/components/command-bar/layout-items/current-layout.ts +1 -1
  41. package/src/components/command-bar/pane-templates/workflow.ts +4 -0
  42. package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -0
  43. package/src/components/command-bar/routes/root/shortcuts.test.ts +19 -0
  44. package/src/components/command-bar/routes/root/shortcuts.ts +3 -1
  45. package/src/components/command-bar/surface/index.test.tsx +6 -6
  46. package/src/components/command-bar/surface/index.tsx +4 -1
  47. package/src/components/command-bar/surface/pane.test.tsx +66 -2
  48. package/src/components/command-bar/view-model.test.ts +2 -1
  49. package/src/components/command-bar/workflow/ops.test.ts +54 -0
  50. package/src/components/command-bar/workflow/ops.ts +1 -1
  51. package/src/components/company-logo.test.ts +19 -0
  52. package/src/components/company-logo.tsx +60 -0
  53. package/src/components/feed-data-table/stack-view.tsx +7 -1
  54. package/src/components/index.ts +1 -0
  55. package/src/components/layout/detached-pane-shell.tsx +85 -15
  56. package/src/components/layout/header.tsx +5 -5
  57. package/src/components/layout/pane/title.ts +31 -3
  58. package/src/components/layout/shell/active-drag.ts +4 -11
  59. package/src/components/layout/shell/drag/index.ts +4 -20
  60. package/src/components/layout/shell/drag/runtime.ts +3 -4
  61. package/src/components/layout/shell/index.test.tsx +17 -3
  62. package/src/components/layout/shell/index.tsx +89 -31
  63. package/src/components/layout/shell/menu.test.ts +37 -1
  64. package/src/components/layout/shell/menu.ts +14 -0
  65. package/src/components/layout/shell/native/pointer-runtime.ts +22 -8
  66. package/src/components/layout/shell/pane/actions.ts +0 -5
  67. package/src/components/layout/shell/pane/management-shortcuts.ts +9 -4
  68. package/src/components/layout/shell/shortcuts.ts +6 -3
  69. package/src/components/layout/shell/ticker-link-menu.test.ts +82 -0
  70. package/src/components/layout/shell/ticker-link-menu.ts +78 -0
  71. package/src/components/layout/status-bar.test.tsx +62 -61
  72. package/src/components/layout/status-bar.tsx +53 -75
  73. package/src/components/onboarding/account-step/form-panel.tsx +4 -2
  74. package/src/components/onboarding/account-step/index.tsx +1 -1
  75. package/src/components/onboarding/account-step/signed-in-panel.tsx +1 -1
  76. package/src/components/onboarding/onboarding-wizard.test.tsx +44 -1
  77. package/src/components/onboarding/onboarding-wizard.tsx +13 -5
  78. package/src/components/onboarding/wizard-account.ts +4 -23
  79. package/src/components/table-view-shared.test.ts +11 -0
  80. package/src/components/table-view-shared.tsx +26 -0
  81. package/src/components/ui/data-table/index.tsx +19 -1
  82. package/src/components/ui/fields.tsx +5 -1
  83. package/src/core/state/app/layout-reducer.ts +36 -0
  84. package/src/core/state/app/layout.ts +10 -5
  85. package/src/core/state/app/state.ts +0 -13
  86. package/src/core/state/app/types.ts +6 -4
  87. package/src/data/config/chart-settings.ts +1 -1
  88. package/src/data/config/layout.ts +2 -0
  89. package/src/data/config/store/index.test.ts +27 -6
  90. package/src/data/config/store/normalize.ts +3 -5
  91. package/src/data/plugin-state-store.ts +11 -0
  92. package/src/data/sqlite/cache.test.ts +10 -0
  93. package/src/i18n/es.ts +13 -5
  94. package/src/i18n/ja.ts +13 -5
  95. package/src/i18n/ko.ts +13 -5
  96. package/src/i18n/zh-cn.ts +13 -5
  97. package/src/i18n/zh-tw.ts +13 -5
  98. package/src/layout-marketplace/api.test.ts +54 -0
  99. package/src/layout-marketplace/api.ts +65 -0
  100. package/src/layout-marketplace/gallery-desktop.test.tsx +262 -0
  101. package/src/layout-marketplace/gallery-desktop.tsx +375 -0
  102. package/src/layout-marketplace/gallery-terminal.tsx +376 -0
  103. package/src/layout-marketplace/gallery.test.tsx +84 -0
  104. package/src/layout-marketplace/gallery.tsx +323 -0
  105. package/src/layout-marketplace/geometry.test.ts +47 -0
  106. package/src/layout-marketplace/geometry.ts +80 -0
  107. package/src/layout-marketplace/mini-workspace.test.tsx +122 -0
  108. package/src/layout-marketplace/mini-workspace.tsx +428 -0
  109. package/src/layout-marketplace/model.test.ts +118 -0
  110. package/src/layout-marketplace/model.ts +171 -0
  111. package/src/layout-marketplace/name-dialog.tsx +60 -0
  112. package/src/layout-marketplace/pane-imagery.ts +85 -0
  113. package/src/layout-marketplace/pane.tsx +20 -0
  114. package/src/layout-marketplace/payload.test.ts +246 -0
  115. package/src/layout-marketplace/payload.ts +624 -0
  116. package/src/layout-marketplace/use-marketplace.ts +63 -0
  117. package/src/market-data/coordinator/index.test.ts +2 -2
  118. package/src/market-data/coordinator/index.ts +1 -1
  119. package/src/market-data/hooks.tsx +0 -41
  120. package/src/market-data/market/format.test.ts +7 -0
  121. package/src/market-data/market/freshness.ts +5 -2
  122. package/src/market-data/quotes/freshness.test.ts +16 -0
  123. package/src/news/aggregator.test.ts +92 -3
  124. package/src/news/aggregator.ts +98 -18
  125. package/src/news/hooks.test.tsx +2 -0
  126. package/src/news/hooks.ts +27 -1
  127. package/src/news/news-model.ts +5 -2
  128. package/src/news/types.ts +7 -0
  129. package/src/plugins/broker-sync/broker-sync.test.ts +58 -0
  130. package/src/plugins/broker-sync/normalize.ts +42 -5
  131. package/src/plugins/builtin/account-management/pane.tsx +5 -5
  132. package/src/plugins/builtin/ai/index.tsx +18 -2
  133. package/src/plugins/builtin/ai/screener/pane.test.tsx +47 -4
  134. package/src/plugins/builtin/ai/screener/pane.tsx +35 -12
  135. package/src/plugins/builtin/alerts/alert-engine.test.ts +53 -1
  136. package/src/plugins/builtin/alerts/alert-engine.ts +34 -1
  137. package/src/plugins/builtin/alerts/index.test.tsx +49 -6
  138. package/src/plugins/builtin/alerts/index.tsx +11 -6
  139. package/src/plugins/builtin/alerts/pane.tsx +51 -9
  140. package/src/plugins/builtin/alerts/quotes.ts +11 -3
  141. package/src/plugins/builtin/alerts/types.ts +1 -0
  142. package/src/plugins/builtin/analytics/index.tsx +11 -1
  143. package/src/plugins/builtin/analytics/pane-model.ts +6 -4
  144. package/src/plugins/builtin/broker-manager/index.tsx +3 -0
  145. package/src/plugins/builtin/buildout/model/index.ts +1 -1
  146. package/src/plugins/builtin/buildout/pane/index.tsx +17 -18
  147. package/src/plugins/builtin/cds/client.test.ts +83 -0
  148. package/src/plugins/builtin/cds/client.ts +67 -0
  149. package/src/plugins/builtin/cds/index.tsx +41 -0
  150. package/src/plugins/builtin/cds/model.test.ts +232 -0
  151. package/src/plugins/builtin/cds/model.ts +401 -0
  152. package/src/plugins/builtin/cds/pane.test.tsx +132 -0
  153. package/src/plugins/builtin/cds/pane.tsx +358 -0
  154. package/src/plugins/builtin/chart-composer/chart-spec.ts +31 -0
  155. package/src/plugins/builtin/chart-composer/index.tsx +88 -2
  156. package/src/plugins/builtin/chart-composer/pane-share.test.ts +71 -0
  157. package/src/plugins/builtin/chart-composer/pane.tsx +103 -15
  158. package/src/plugins/builtin/chat/content/index.tsx +4 -1
  159. package/src/plugins/builtin/chat/controller/channels.ts +7 -1
  160. package/src/plugins/builtin/chat/controller/index.ts +14 -9
  161. package/src/plugins/builtin/chat/controller/notifications.ts +13 -3
  162. package/src/plugins/builtin/chat/controller/persistence.ts +0 -3
  163. package/src/plugins/builtin/chat/controller/realtime.ts +3 -3
  164. package/src/plugins/builtin/chat/controller/send.ts +1 -3
  165. package/src/plugins/builtin/chat/controller/session-runtime.ts +18 -11
  166. package/src/plugins/builtin/chat/controller/state.ts +1 -3
  167. package/src/plugins/builtin/chat/controller/storage.ts +0 -2
  168. package/src/plugins/builtin/chat/controller/utils.ts +4 -6
  169. package/src/plugins/builtin/chat/controller/view.ts +2 -2
  170. package/src/plugins/builtin/chat/controller.test.ts +142 -14
  171. package/src/plugins/builtin/chat/message/desktop.test.tsx +88 -0
  172. package/src/plugins/builtin/chat/message/desktop.tsx +3 -1
  173. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +1 -29
  174. package/src/plugins/builtin/chat/profile-popover.ts +53 -22
  175. package/src/plugins/builtin/chat/sidebar.test.tsx +81 -0
  176. package/src/plugins/builtin/chat/sidebar.tsx +63 -0
  177. package/src/plugins/builtin/chat/test-harness.tsx +29 -1
  178. package/src/plugins/builtin/cloud/auth-actions.tsx +14 -7
  179. package/src/plugins/builtin/cloud/auth-commands.ts +21 -51
  180. package/src/plugins/builtin/cloud/auth-dialog.tsx +296 -0
  181. package/src/{components/onboarding/account-step/model.test.ts → plugins/builtin/cloud/auth-model.test.ts} +1 -1
  182. package/src/{components/onboarding/account-step/model.ts → plugins/builtin/cloud/auth-model.ts} +37 -3
  183. package/src/plugins/builtin/cloud/browser.tsx +9 -0
  184. package/src/plugins/builtin/cloud/buildout-module.tsx +23 -0
  185. package/src/plugins/builtin/cloud/device-signin.ts +2 -8
  186. package/src/plugins/builtin/cloud/index.tsx +2 -0
  187. package/src/plugins/builtin/cloud/plugin.tsx +14 -24
  188. package/src/plugins/builtin/cloud/upgrade-command.ts +13 -6
  189. package/src/plugins/builtin/cloud-tweets/pane-share.test.ts +56 -0
  190. package/src/plugins/builtin/cloud-tweets/pane.tsx +20 -5
  191. package/src/plugins/builtin/cloud-tweets/registration.ts +50 -6
  192. package/src/plugins/builtin/composite-plugins.ts +3 -1
  193. package/src/plugins/builtin/congress-trades/detail.tsx +43 -1
  194. package/src/plugins/builtin/congress-trades/model.test.ts +85 -0
  195. package/src/plugins/builtin/congress-trades/model.ts +69 -1
  196. package/src/plugins/builtin/congress-trades/pane.tsx +51 -2
  197. package/src/plugins/builtin/connections/index.ts +3 -0
  198. package/src/plugins/builtin/dividend-yield/client.ts +18 -0
  199. package/src/plugins/builtin/dividend-yield/pane.tsx +7 -6
  200. package/src/plugins/builtin/earnings/index.tsx +5 -3
  201. package/src/plugins/builtin/earnings/model.test.ts +19 -1
  202. package/src/plugins/builtin/earnings/model.ts +17 -15
  203. package/src/plugins/builtin/econ/calendar-model.ts +0 -5
  204. package/src/plugins/builtin/econ/index.test.tsx +6 -1
  205. package/src/plugins/builtin/fear-greed/charts.tsx +12 -8
  206. package/src/plugins/builtin/help/index.tsx +2 -2
  207. package/src/plugins/builtin/holders/thirteenf-match.ts +1 -2
  208. package/src/plugins/builtin/insider/index.tsx +27 -11
  209. package/src/plugins/builtin/kelly-sizer/index.tsx +4 -1
  210. package/src/plugins/builtin/layout-manager/index.tsx +17 -4
  211. package/src/plugins/builtin/news/index.tsx +11 -6
  212. package/src/plugins/builtin/news/wire/breaking/notifications.test.ts +2 -0
  213. package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +2 -0
  214. package/src/plugins/builtin/news/wire/breaking/pane.tsx +4 -1
  215. package/src/plugins/builtin/news/wire/feed-config.test.ts +15 -1
  216. package/src/plugins/builtin/news/wire/feed-config.ts +25 -5
  217. package/src/plugins/builtin/news/wire/index.tsx +3 -3
  218. package/src/plugins/builtin/news/wire/industry-pane.tsx +7 -2
  219. package/src/plugins/builtin/news/wire/news/footer.ts +34 -1
  220. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +4 -1
  221. package/src/plugins/builtin/news/wire/news/table.tsx +8 -2
  222. package/src/plugins/builtin/news/wire/rss/source.test.ts +12 -0
  223. package/src/plugins/builtin/news/wire/rss/source.ts +1 -1
  224. package/src/plugins/builtin/notes/files.ts +80 -0
  225. package/src/plugins/builtin/notes/index.tsx +3 -0
  226. package/src/plugins/builtin/notes/sync.test.ts +49 -0
  227. package/src/plugins/builtin/notes/sync.ts +98 -0
  228. package/src/plugins/builtin/options/index.tsx +1 -0
  229. package/src/plugins/builtin/options/view.test.tsx +25 -3
  230. package/src/plugins/builtin/options/view.tsx +1 -0
  231. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +22 -0
  232. package/src/plugins/builtin/portfolio-list/account-metrics.ts +12 -7
  233. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
  234. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -4
  235. package/src/plugins/builtin/portfolio-list/header.tsx +13 -3
  236. package/src/plugins/builtin/portfolio-list/index.tsx +8 -0
  237. package/src/plugins/builtin/portfolio-list/metrics.test.ts +31 -1
  238. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +1 -0
  239. package/src/plugins/builtin/portfolio-list/pane/index.tsx +10 -7
  240. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +6 -1
  241. package/src/plugins/builtin/portfolio-list/position-metrics.ts +25 -6
  242. package/src/plugins/builtin/portfolio-list/summary/index.tsx +35 -21
  243. package/src/plugins/builtin/portfolio-list/summary/totals.ts +20 -6
  244. package/src/plugins/builtin/research/corporate-actions-pane.tsx +5 -12
  245. package/src/plugins/builtin/research/equity-diagnostic-pane.test.tsx +329 -0
  246. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +582 -0
  247. package/src/plugins/builtin/research/index.test.ts +14 -0
  248. package/src/plugins/builtin/research/index.tsx +29 -0
  249. package/src/plugins/builtin/sec/index.tsx +24 -9
  250. package/src/plugins/builtin/sectors/index.tsx +14 -0
  251. package/src/plugins/builtin/shared/cloud-upgrade.ts +18 -11
  252. package/src/plugins/builtin/shared/public-pane.test.ts +12 -0
  253. package/src/plugins/builtin/shared/public-pane.ts +9 -0
  254. package/src/plugins/builtin/shared/public-share.ts +23 -0
  255. package/src/plugins/builtin/shared/ticker-source.ts +32 -0
  256. package/src/plugins/builtin/shared/ticker-surface.test.ts +27 -0
  257. package/src/plugins/builtin/shared/ticker-surface.ts +17 -0
  258. package/src/plugins/builtin/substack/api/cache.ts +7 -3
  259. package/src/plugins/builtin/substack/api/loaders.ts +36 -12
  260. package/src/plugins/builtin/substack/api/types.ts +2 -0
  261. package/src/plugins/builtin/substack/pane-state.ts +1 -1
  262. package/src/plugins/builtin/substack/pane.tsx +55 -12
  263. package/src/plugins/builtin/thirteenf/api.test.ts +1 -1
  264. package/src/plugins/builtin/thirteenf/api.ts +40 -18
  265. package/src/plugins/builtin/thirteenf/data.ts +15 -7
  266. package/src/plugins/builtin/thirteenf/pane.tsx +45 -10
  267. package/src/plugins/builtin/ticker-detail/index.tsx +10 -0
  268. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +12 -4
  269. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +8 -2
  270. package/src/plugins/builtin/ticker-detail/settings.ts +12 -0
  271. package/src/plugins/builtin/treasury-auctions/index.tsx +2 -0
  272. package/src/plugins/builtin/world-indices/index.tsx +2 -0
  273. package/src/plugins/catalog-browser.test.ts +78 -0
  274. package/src/plugins/catalog-browser.ts +136 -0
  275. package/src/plugins/gridlock-notification.ts +2 -1
  276. package/src/plugins/ibkr/flex/index.test.ts +19 -0
  277. package/src/plugins/ibkr/flex/index.ts +1 -1
  278. package/src/plugins/pane-manager/gridlock.ts +87 -3
  279. package/src/plugins/pane-manager.test.ts +30 -0
  280. package/src/plugins/pane-manager.ts +6 -1
  281. package/src/plugins/prediction-markets/chart.tsx +2 -2
  282. package/src/plugins/prediction-markets/controller/catalog.ts +77 -2
  283. package/src/plugins/prediction-markets/controller/data.ts +7 -1
  284. package/src/plugins/prediction-markets/controller/index.ts +9 -2
  285. package/src/plugins/prediction-markets/controller/keyboard.ts +10 -14
  286. package/src/plugins/prediction-markets/detail/overview.tsx +21 -0
  287. package/src/plugins/prediction-markets/detail/pane.tsx +43 -34
  288. package/src/plugins/prediction-markets/detail/trades.tsx +34 -9
  289. package/src/plugins/prediction-markets/detail.test.tsx +29 -2
  290. package/src/plugins/prediction-markets/index.tsx +8 -4
  291. package/src/plugins/prediction-markets/navigation.ts +0 -1
  292. package/src/plugins/prediction-markets/pane.test.tsx +77 -0
  293. package/src/plugins/prediction-markets/pane.tsx +12 -0
  294. package/src/plugins/prediction-markets/plugin.test.tsx +36 -0
  295. package/src/plugins/prediction-markets/services/kalshi/adapter.ts +49 -9
  296. package/src/plugins/prediction-markets/services/polymarket/adapter.ts +31 -0
  297. package/src/plugins/prediction-markets/test-helpers.tsx +9 -0
  298. package/src/plugins/registry/index.ts +2 -0
  299. package/src/plugins/ticker-navigation.test.ts +24 -0
  300. package/src/plugins/ticker-navigation.ts +40 -0
  301. package/src/remote/schema.ts +1 -1
  302. package/src/renderers/browser/app-services.ts +61 -0
  303. package/src/renderers/browser/cloud-transport.test.ts +68 -0
  304. package/src/renderers/browser/cloud-transport.ts +48 -0
  305. package/src/renderers/browser/config-host.ts +58 -0
  306. package/src/renderers/browser/deeplink-bridge.test.ts +41 -0
  307. package/src/renderers/browser/deeplink-bridge.ts +22 -0
  308. package/src/renderers/browser/error-boundary.tsx +23 -0
  309. package/src/renderers/browser/main.tsx +64 -0
  310. package/src/renderers/browser/persistence.ts +74 -0
  311. package/src/renderers/browser/storage.test.ts +66 -0
  312. package/src/renderers/browser/storage.ts +50 -0
  313. package/src/renderers/browser/ticker-repository.ts +59 -0
  314. package/src/renderers/browser/ui-host.tsx +40 -0
  315. package/src/renderers/cloudflare/worker.test.ts +136 -0
  316. package/src/renderers/cloudflare/worker.ts +116 -0
  317. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  318. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  319. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +7 -1
  320. package/src/renderers/electrobun/view/app-services.ts +14 -3
  321. package/src/renderers/electrobun/view/build-assets.ts +12 -10
  322. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -1
  323. package/src/renderers/electrobun/view/data-table/index.tsx +10 -1
  324. package/src/renderers/electrobun/view/desktop/controls.tsx +2 -0
  325. package/src/renderers/electrobun/view/dom-ui-host.tsx +143 -0
  326. package/src/renderers/electrobun/view/host/box.test.tsx +33 -0
  327. package/src/renderers/electrobun/view/host/box.tsx +11 -6
  328. package/src/renderers/electrobun/view/host/chart-surface.tsx +7 -1
  329. package/src/renderers/electrobun/view/host/input.tsx +11 -7
  330. package/src/renderers/electrobun/view/host/native.ts +0 -5
  331. package/src/renderers/electrobun/view/main.tsx +2 -0
  332. package/src/renderers/electrobun/view/remote/persistence.ts +5 -0
  333. package/src/renderers/electrobun/view/styles.css +107 -7
  334. package/src/renderers/electrobun/view/toast-host.test.tsx +78 -0
  335. package/src/renderers/electrobun/view/toast-host.tsx +69 -43
  336. package/src/renderers/electrobun/view/ui-host.tsx +6 -137
  337. package/src/renderers/opentui/start.tsx +2 -0
  338. package/src/renderers/share/layout-view.test.tsx +58 -0
  339. package/src/renderers/share/layout-view.tsx +133 -0
  340. package/src/renderers/share/main.tsx +102 -0
  341. package/src/renderers/share/styles.css +54 -0
  342. package/src/renderers/share/view.test.tsx +100 -0
  343. package/src/renderers/share/view.tsx +150 -0
  344. package/src/shares/api.test.ts +105 -0
  345. package/src/shares/api.ts +127 -0
  346. package/src/shares/chart-snapshot.test.ts +34 -0
  347. package/src/shares/chart-snapshot.ts +28 -0
  348. package/src/shares/location.test.ts +19 -0
  349. package/src/shares/location.ts +11 -0
  350. package/src/shares/pane.test.ts +110 -0
  351. package/src/shares/pane.ts +54 -0
  352. package/src/shares/payload.ts +177 -0
  353. package/src/sources/gloomberb-cloud/index.test.ts +61 -1
  354. package/src/sources/gloomberb-cloud/index.ts +86 -14
  355. package/src/sources/gloomberb-cloud/normalizers.test.ts +34 -0
  356. package/src/sources/gloomberb-cloud/normalizers.ts +5 -5
  357. package/src/sources/provider-router/history.test.ts +2 -2
  358. package/src/sources/provider-router/news.test.ts +34 -0
  359. package/src/sources/provider-router/news.ts +15 -1
  360. package/src/sources/sec-edgar.test.ts +59 -0
  361. package/src/sources/sec-edgar.ts +63 -23
  362. package/src/sources/yahoo-finance/mappers.ts +3 -0
  363. package/src/sources/yahoo-finance/snapshots.ts +10 -2
  364. package/src/sources/yahoo-finance/symbols.test.ts +10 -0
  365. package/src/sources/yahoo-finance/symbols.ts +13 -8
  366. package/src/sources/yahoo-finance.test.ts +1 -1
  367. package/src/sources/yahoo-finance.ts +6 -2
  368. package/src/state/app/bootstrap.ts +6 -7
  369. package/src/state/app/context/index.test.ts +41 -1
  370. package/src/state/app/context/index.test.tsx +34 -16
  371. package/src/state/selectors-ui.ts +0 -8
  372. package/src/sync/baseline.ts +32 -0
  373. package/src/sync/controller.test.ts +124 -1
  374. package/src/sync/controller.ts +54 -12
  375. package/src/sync/core-contributors.test.ts +44 -0
  376. package/src/sync/core-contributors.ts +59 -10
  377. package/src/sync/react.ts +22 -2
  378. package/src/sync/types.ts +12 -0
  379. package/src/theme/font-scale.ts +0 -11
  380. package/src/tickers/search/index.test.ts +27 -0
  381. package/src/tickers/search/upsert.ts +10 -0
  382. package/src/types/capability-route-source.ts +2 -1
  383. package/src/types/config.ts +35 -5
  384. package/src/types/desktop-menu.ts +1 -0
  385. package/src/types/plugin.ts +36 -0
  386. package/src/ui/context-menu.tsx +1 -1
  387. package/src/ui/host.tsx +4 -0
  388. package/src/ui/toast.tsx +2 -0
  389. package/src/utils/back-navigation.ts +0 -4
  390. package/src/utils/price-history.ts +11 -10
  391. package/src/utils/sec.test.ts +6 -0
  392. package/src/utils/sec.ts +1 -1
@@ -386,7 +386,7 @@ export class MarketDataCoordinator {
386
386
  && typeof quote.lastUpdated === "number"
387
387
  && quote.lastUpdated < snapshot.quote.lastUpdated
388
388
  ) {
389
- return quote;
389
+ return snapshot.quote;
390
390
  }
391
391
  }
392
392
  const cachedFinancials = snapshot ?? this.readCachedFinancialsForInstrument(instrument);
@@ -213,47 +213,6 @@ export function useQuoteEntries(
213
213
  }, [coordinator, instrumentKey, keysVersion]);
214
214
  }
215
215
 
216
- export function useChartQuery(
217
- request: ChartRequest | null | undefined,
218
- options: ChartQueryOptions = {},
219
- ): QueryEntry<PricePoint[]> | null {
220
- const key = request ? buildChartKey(request) : null;
221
- useCoordinatorKeysVersion(key ? [key] : []);
222
- const coordinator = getSharedMarketDataCoordinator();
223
- const entry = coordinator && request ? coordinator.getChartEntry(request) : null;
224
- const appActive = useAppActive();
225
- const wasActiveRef = useRef(appActive);
226
- const refreshIntervalMs = Math.max(0, options.refreshIntervalMs ?? 0);
227
-
228
- useEffect(() => {
229
- const coordinator = getSharedMarketDataCoordinator();
230
- if (!coordinator || !request) {
231
- wasActiveRef.current = appActive;
232
- return;
233
- }
234
- if (!appActive) {
235
- wasActiveRef.current = false;
236
- return;
237
- }
238
-
239
- const forceRefresh = refreshIntervalMs > 0 && !wasActiveRef.current;
240
- wasActiveRef.current = true;
241
- void coordinator.loadChart(request, { forceRefresh }).catch(() => {});
242
- }, [appActive, key, refreshIntervalMs]);
243
-
244
- useEffect(() => {
245
- const coordinator = getSharedMarketDataCoordinator();
246
- if (!coordinator || !request || !appActive || refreshIntervalMs <= 0) return;
247
-
248
- const interval = setInterval(() => {
249
- void coordinator.loadChart(request, { forceRefresh: true }).catch(() => {});
250
- }, refreshIntervalMs);
251
- return () => clearInterval(interval);
252
- }, [appActive, key, refreshIntervalMs]);
253
-
254
- return entry;
255
- }
256
-
257
216
  export function useChartQueries(
258
217
  requests: readonly ChartRequest[],
259
218
  options: ChartQueryOptions = {},
@@ -100,6 +100,13 @@ describe("formatMarketPriceWithCurrency", () => {
100
100
  test("preserves chart-style compact notation for large values", () => {
101
101
  expect(formatCompactMarketPriceWithCurrency(21_970, "JPY")).toBe("¥22.0K");
102
102
  expect(formatCompactMarketPriceWithCurrency(12_340, "HKD")).toBe("HK$12.3K");
103
+ expect(formatCompactMarketPriceWithCurrency(79_432.18, "USD", { assetCategory: "CRYPTO" })).toBe("$79.4K");
104
+ });
105
+
106
+ test("keeps full price precision for legend-style values", () => {
107
+ expect(formatMarketPriceWithCurrency(79_432.18, "USD", { assetCategory: "CRYPTO" })).toBe("$79,432.18");
108
+ expect(formatMarketPriceWithCurrency(79_432.18, "USD", { assetCategory: "STK" })).toBe("$79,432.18");
109
+ expect(formatMarketPriceWithCurrency(1.084567, "USD", { isCashBalance: true })).toBe("$1.084567");
103
110
  });
104
111
 
105
112
  test("formats position cost values with tighter equity precision", () => {
@@ -5,6 +5,7 @@ const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "
5
5
  const ALWAYS_OPEN_EXCHANGES = new Set(["CCC"]);
6
6
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
7
7
  const OVERNIGHT_CLOSE_MAX_AGE_MS = 20 * 60 * 60 * 1000;
8
+ const ALWAYS_OPEN_MAX_AGE_MS = 2 * 60 * 60 * 1000;
8
9
  const REGULAR_OPEN_MINUTES: Record<string, number> = {
9
10
  NASDAQ: 9 * 60 + 30,
10
11
  NYSE: 9 * 60 + 30,
@@ -174,11 +175,13 @@ function isTimestampStaleForExchangeSessionUnsafe(
174
175
  const canonical = canonicalExchange(exchange);
175
176
  if (!canonical || !Number.isFinite(timestampMs) || !Number.isFinite(now)) return false;
176
177
 
178
+ if (ALWAYS_OPEN_EXCHANGES.has(canonical)) {
179
+ return now - timestampMs > ALWAYS_OPEN_MAX_AGE_MS;
180
+ }
181
+
177
182
  const timestampDate = exchangeLocalDate(canonical, timestampMs);
178
183
  const currentDate = exchangeLocalDate(canonical, now);
179
184
  if (!timestampDate || !currentDate || timestampDate === currentDate) return false;
180
-
181
- if (ALWAYS_OPEN_EXCHANGES.has(canonical)) return true;
182
185
  if (marketState === "REGULAR" && !isBeforeKnownRegularOpen(canonical, now)) return true;
183
186
 
184
187
  if (isUsExtendedHoursExchange(canonical)) {
@@ -58,6 +58,22 @@ describe("quote freshness", () => {
58
58
  ).toBe(true);
59
59
  });
60
60
 
61
+ test("treats same-day crypto quotes older than two hours as stale", () => {
62
+ expect(
63
+ isQuoteStaleForCurrentSession(
64
+ quote({
65
+ symbol: "BTC-USD",
66
+ currency: "USD",
67
+ lastUpdated: Date.parse("2026-05-13T12:00:00Z"),
68
+ listingExchangeName: "CCC",
69
+ exchangeName: "CCC",
70
+ marketState: "REGULAR",
71
+ }),
72
+ Date.parse("2026-05-13T21:00:00Z"),
73
+ ),
74
+ ).toBe(true);
75
+ });
76
+
61
77
  test("treats old crypto quotes as stale on the 24/7 venue", () => {
62
78
  expect(
63
79
  isQuoteStaleForCurrentSession(
@@ -214,8 +214,8 @@ describe("NewsService", () => {
214
214
  expect(breaking.every((i) => i.url !== "https://f.com/old-high")).toBe(true);
215
215
  });
216
216
 
217
- it("retains at most 500 articles", async () => {
218
- const items = Array.from({ length: 600 }, (_, i) =>
217
+ it("retains at most 10000 articles", async () => {
218
+ const items = Array.from({ length: 10_100 }, (_, i) =>
219
219
  makeItem({
220
220
  url: `https://g.com/${i}`,
221
221
  publishedAt: new Date(Date.now() - i * 1000),
@@ -224,7 +224,7 @@ describe("NewsService", () => {
224
224
  agg.register(makeSource("g", items));
225
225
  await agg.poll();
226
226
 
227
- expect(agg.getFirehose(undefined, 1000)).toHaveLength(500);
227
+ expect(agg.getFirehose(undefined, 20_000)).toHaveLength(10_000);
228
228
  });
229
229
 
230
230
  it("subscribe callback fires on poll and getVersion increments", async () => {
@@ -488,4 +488,93 @@ describe("NewsService", () => {
488
488
 
489
489
  expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-1"]);
490
490
  });
491
+
492
+ it("appends paged news instead of replacing the first page", async () => {
493
+ const first = makeItem({ url: "https://example.com/a", title: "First" });
494
+ const second = makeItem({ url: "https://example.com/b", title: "Second" });
495
+ const fetchNewsPage = mock(async (query: { cursor?: string }) => (
496
+ query.cursor === "page-2"
497
+ ? { articles: [second], nextCursor: null }
498
+ : { articles: [first], nextCursor: "page-2" }
499
+ ));
500
+ agg.register(newsProvider({
501
+ id: "cloud",
502
+ name: "cloud",
503
+ provider: {
504
+ fetchNews: async (query) => (await fetchNewsPage(query)).articles,
505
+ fetchNewsPage,
506
+ },
507
+ }));
508
+
509
+ const initial = await agg.load({ feed: "latest", limit: 50 });
510
+ expect(initial.articles.map((article) => article.url)).toEqual(["https://example.com/a"]);
511
+ expect(initial.nextCursor).toBe("page-2");
512
+
513
+ await agg.loadMore({ feed: "latest", limit: 50 });
514
+ const state = agg.getQueryState({ feed: "latest", limit: 50 });
515
+ expect(state.articles.map((article) => article.url)).toEqual([
516
+ "https://example.com/a",
517
+ "https://example.com/b",
518
+ ]);
519
+ expect(state.nextCursor).toBeNull();
520
+ });
521
+
522
+ it("keeps paged news past the query page size", async () => {
523
+ const first = makeItem({ url: "https://example.com/a", title: "First" });
524
+ const second = makeItem({ url: "https://example.com/b", title: "Second" });
525
+ const fetchNewsPage = mock(async (query: { cursor?: string }) => (
526
+ query.cursor === "page-2"
527
+ ? { articles: [second], nextCursor: "page-3" }
528
+ : { articles: [first], nextCursor: "page-2" }
529
+ ));
530
+ agg.register(newsProvider({
531
+ id: "cloud",
532
+ name: "cloud",
533
+ provider: {
534
+ fetchNews: async (query) => (await fetchNewsPage(query)).articles,
535
+ fetchNewsPage,
536
+ },
537
+ }));
538
+
539
+ await agg.load({ feed: "latest", limit: 1 });
540
+ await agg.loadMore({ feed: "latest", limit: 1 });
541
+ const state = agg.getQueryState({ feed: "latest", limit: 1 });
542
+ expect(state.articles.map((article) => article.url)).toEqual([
543
+ "https://example.com/a",
544
+ "https://example.com/b",
545
+ ]);
546
+ expect(state.nextCursor).toBe("page-3");
547
+ });
548
+
549
+ it("refresh keeps already paged news", async () => {
550
+ const first = makeItem({ url: "https://example.com/a", title: "First", publishedAt: new Date("2026-01-02") });
551
+ const second = makeItem({ url: "https://example.com/b", title: "Second", publishedAt: new Date("2026-01-01") });
552
+ const newer = makeItem({ url: "https://example.com/c", title: "Newer", publishedAt: new Date("2026-01-03") });
553
+ let head: "initial" | "refresh" = "initial";
554
+ const fetchNewsPage = mock(async (query: { cursor?: string }) => {
555
+ if (query.cursor === "page-2") return { articles: [second], nextCursor: "page-3" };
556
+ if (head === "refresh") return { articles: [newer, first], nextCursor: "page-2" };
557
+ head = "refresh";
558
+ return { articles: [first], nextCursor: "page-2" };
559
+ });
560
+ agg.register(newsProvider({
561
+ id: "cloud",
562
+ name: "cloud",
563
+ provider: {
564
+ fetchNews: async (query) => (await fetchNewsPage(query)).articles,
565
+ fetchNewsPage,
566
+ },
567
+ }));
568
+
569
+ await agg.load({ feed: "latest", limit: 1 });
570
+ await agg.loadMore({ feed: "latest", limit: 1 });
571
+ await agg.poll({ feed: "latest", limit: 1 });
572
+ const state = agg.getQueryState({ feed: "latest", limit: 1 });
573
+ expect(state.articles.map((article) => article.url)).toEqual([
574
+ "https://example.com/c",
575
+ "https://example.com/a",
576
+ "https://example.com/b",
577
+ ]);
578
+ expect(state.nextCursor).toBe("page-3");
579
+ });
491
580
  });
@@ -3,6 +3,7 @@ import type { ConnectionHealthRegistry } from "../core/connection-health";
3
3
  import type { NewsArticle, NewsQuery, NewsQueryState } from "./types";
4
4
  import {
5
5
  DEFAULT_GLOBAL_QUERY,
6
+ MAX_ARTICLES,
6
7
  buildNewsQueryKey,
7
8
  createIdleNewsQueryState,
8
9
  dedupeNewsArticles,
@@ -36,12 +37,14 @@ interface SourceFetchResult {
36
37
  articles: NewsArticle[];
37
38
  sourceIds: string[];
38
39
  failedSourceIds: string[];
40
+ nextCursor: string | null;
39
41
  }
40
42
 
41
43
  interface NewsQueryEntry {
42
44
  query: NewsQuery;
43
45
  state: NewsQueryState;
44
46
  inFlight: Promise<NewsQueryState> | null;
47
+ loadMoreInFlight: Promise<void> | null;
45
48
  refs: number;
46
49
  lastAccessedAt: number;
47
50
  }
@@ -169,6 +172,46 @@ export class NewsService {
169
172
  return this.refreshQuery(normalizeNewsQuery(query), true);
170
173
  }
171
174
 
175
+ async loadMore(query: NewsQuery): Promise<void> {
176
+ const normalized = normalizeNewsQuery(query);
177
+ const entry = this.queries.get(buildNewsQueryKey(normalized));
178
+ if (!entry || entry.loadMoreInFlight || !entry.state.nextCursor || entry.state.loadingMore) return;
179
+ if (entry.state.articles.length >= MAX_ARTICLES) {
180
+ entry.state = { ...entry.state, nextCursor: null };
181
+ this.notify();
182
+ return;
183
+ }
184
+
185
+ const request = (async () => {
186
+ entry.state = { ...entry.state, loadingMore: true };
187
+ this.notify();
188
+ try {
189
+ const result = await this.fetchFromSources({
190
+ ...normalized,
191
+ cursor: entry.state.nextCursor ?? undefined,
192
+ });
193
+ entry.state = {
194
+ ...entry.state,
195
+ loadingMore: false,
196
+ articles: filterNewsArticlesForQuery(
197
+ dedupeNewsArticles([...entry.state.articles, ...result.articles]),
198
+ normalized,
199
+ ),
200
+ nextCursor: result.nextCursor,
201
+ };
202
+ this.rebuildArticlePool();
203
+ this.notify();
204
+ } catch {
205
+ entry.state = { ...entry.state, loadingMore: false };
206
+ this.notify();
207
+ } finally {
208
+ entry.loadMoreInFlight = null;
209
+ }
210
+ })();
211
+ entry.loadMoreInFlight = request;
212
+ await request;
213
+ }
214
+
172
215
  async loadStory(storyId: string): Promise<NewsArticle | null> {
173
216
  const sources = this.enabledSources({ feed: "latest" })
174
217
  .filter((source) => !!source.provider.fetchNewsStory);
@@ -227,7 +270,14 @@ export class NewsService {
227
270
  if (result.sourceIds.length === 0 && result.failedSourceIds.length > 0) {
228
271
  throw new Error("News sources unavailable.");
229
272
  }
230
- const articles = filterNewsArticlesForQuery(dedupeNewsArticles(result.articles), query);
273
+ if (entry.loadMoreInFlight) return entry.state;
274
+ const incoming = filterNewsArticlesForQuery(dedupeNewsArticles(result.articles), query);
275
+ const existing = entry.state.articles;
276
+ const incomingIds = new Set(incoming.map((article) => article.id));
277
+ const hasOlderPages = existing.some((article) => !incomingIds.has(article.id));
278
+ const articles = existing.length > 0
279
+ ? filterNewsArticlesForQuery(dedupeNewsArticles([...incoming, ...existing]), query)
280
+ : incoming;
231
281
  const state: NewsQueryState = {
232
282
  phase: "ready",
233
283
  articles,
@@ -238,6 +288,8 @@ export class NewsService {
238
288
  : null,
239
289
  updatedAt: this.now(),
240
290
  sourceIds: result.sourceIds,
291
+ nextCursor: hasOlderPages ? entry.state.nextCursor : result.nextCursor,
292
+ loadingMore: entry.state.loadingMore,
241
293
  };
242
294
  entry.state = state;
243
295
  entry.lastAccessedAt = this.now();
@@ -277,6 +329,7 @@ export class NewsService {
277
329
  query,
278
330
  state: createIdleNewsQueryState(),
279
331
  inFlight: null,
332
+ loadMoreInFlight: null,
280
333
  refs: 0,
281
334
  lastAccessedAt: now,
282
335
  };
@@ -313,10 +366,36 @@ export class NewsService {
313
366
 
314
367
  private async fetchFromSources(query: NewsQuery): Promise<SourceFetchResult> {
315
368
  const sources = this.enabledSources(query);
369
+ const pageSources = query.cursor
370
+ ? sources.filter((source) => !!source.provider.fetchNewsPage)
371
+ : sources;
316
372
  if (normalizeNewsFeed(query) === "ticker") {
317
- return this.fetchTickerNews(query, sources);
373
+ return this.fetchTickerNews(query, pageSources);
318
374
  }
319
- return this.fetchMergedNews(query, sources);
375
+ return this.fetchMergedNews(query, pageSources);
376
+ }
377
+
378
+ private async readSourcePage(
379
+ source: NewsCapability,
380
+ query: NewsQuery,
381
+ ): Promise<{ articles: NewsArticle[]; nextCursor: string | null }> {
382
+ if (source.provider.fetchNewsPage) {
383
+ const page = await this.trackSourceRequest(
384
+ source,
385
+ "fetchNewsPage",
386
+ () => source.provider.fetchNewsPage!(query),
387
+ );
388
+ return {
389
+ articles: page.articles.map((article) => markDetailCapableArticle(source, article)),
390
+ nextCursor: page.nextCursor ?? null,
391
+ };
392
+ }
393
+ const articles = (await this.trackSourceRequest(
394
+ source,
395
+ "fetchNews",
396
+ () => source.provider.fetchNews(query),
397
+ )).map((article) => markDetailCapableArticle(source, article));
398
+ return { articles, nextCursor: null };
320
399
  }
321
400
 
322
401
  private async fetchTickerNews(query: NewsQuery, sources: NewsCapability[]): Promise<SourceFetchResult> {
@@ -324,45 +403,44 @@ export class NewsService {
324
403
  const failedSourceIds: string[] = [];
325
404
  for (const source of sources) {
326
405
  try {
327
- const articles = (await this.trackSourceRequest(
328
- source,
329
- "fetchNews",
330
- () => source.provider.fetchNews(query),
331
- )).map((article) => markDetailCapableArticle(source, article));
332
- const result = { articles, sourceIds: [newsCapabilitySourceId(source)], failedSourceIds };
333
- if (articles.length > 0) return result;
406
+ const page = await this.readSourcePage(source, query);
407
+ const result = {
408
+ articles: page.articles,
409
+ sourceIds: [newsCapabilitySourceId(source)],
410
+ failedSourceIds,
411
+ nextCursor: page.nextCursor,
412
+ };
413
+ if (page.articles.length > 0) return result;
334
414
  firstEmpty ??= result;
335
415
  } catch {
336
416
  failedSourceIds.push(newsCapabilitySourceId(source));
337
417
  }
338
418
  }
339
- return firstEmpty ?? { articles: [], sourceIds: [], failedSourceIds };
419
+ return firstEmpty ?? { articles: [], sourceIds: [], failedSourceIds, nextCursor: null };
340
420
  }
341
421
 
342
422
  private async fetchMergedNews(query: NewsQuery, sources: NewsCapability[]): Promise<SourceFetchResult> {
343
423
  const settled = await Promise.allSettled(
344
424
  sources.map(async (source) => ({
345
425
  source,
346
- articles: (await this.trackSourceRequest(
347
- source,
348
- "fetchNews",
349
- () => source.provider.fetchNews(query),
350
- )).map((article) => markDetailCapableArticle(source, article)),
426
+ page: await this.readSourcePage(source, query),
351
427
  })),
352
428
  );
353
429
  const articles: NewsArticle[] = [];
354
430
  const sourceIds: string[] = [];
355
431
  const failedSourceIds: string[] = [];
432
+ let nextCursor: string | null = null;
356
433
  settled.forEach((result, index) => {
357
434
  if (result.status !== "fulfilled") {
358
435
  const source = sources[index];
359
436
  if (source) failedSourceIds.push(newsCapabilitySourceId(source));
360
437
  return;
361
438
  }
362
- articles.push(...result.value.articles);
439
+ articles.push(...result.value.page.articles);
363
440
  sourceIds.push(newsCapabilitySourceId(result.value.source));
441
+ nextCursor ??= result.value.page.nextCursor;
364
442
  });
365
- return { articles, sourceIds, failedSourceIds };
443
+ return { articles, sourceIds, failedSourceIds, nextCursor };
366
444
  }
367
445
 
368
446
  private trackSourceRequest<T>(
@@ -393,6 +471,8 @@ export class NewsService {
393
471
  error: null,
394
472
  updatedAt: this.now(),
395
473
  sourceIds: [...new Set([...entry.state.sourceIds, newsCapabilitySourceId(source)])],
474
+ nextCursor: entry.state.nextCursor,
475
+ loadingMore: false,
396
476
  };
397
477
  changed = true;
398
478
  }
@@ -33,6 +33,8 @@ test("useNewsArticles watches once and unwatches on unmount", async () => {
33
33
  error: null,
34
34
  updatedAt: 1,
35
35
  sourceIds: [],
36
+ nextCursor: null,
37
+ loadingMore: false,
36
38
  };
37
39
  const service = {
38
40
  getQueryState: () => readyState,
package/src/news/hooks.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { useCallback, useSyncExternalStore } from "react";
1
+ import { useCallback, useRef, useSyncExternalStore } from "react";
2
2
  import { buildNewsQueryKey, type NewsService } from "./aggregator";
3
3
  import type { NewsArticle, NewsQuery, NewsQueryState } from "./types";
4
+ import { useTableLoadMore } from "../components/table-view-shared";
5
+ import type { ScrollBoxRenderable } from "../ui";
4
6
 
5
7
  let sharedService: NewsService | null = null;
6
8
 
@@ -18,6 +20,8 @@ const IDLE_NEWS_QUERY_STATE: NewsQueryState = {
18
20
  error: null,
19
21
  updatedAt: null,
20
22
  sourceIds: [],
23
+ nextCursor: null,
24
+ loadingMore: false,
21
25
  };
22
26
 
23
27
  export function useNewsArticles(query: NewsQuery | null | undefined): NewsQueryState {
@@ -38,3 +42,25 @@ export function useNewsArticles(query: NewsQuery | null | undefined): NewsQueryS
38
42
  export function useLoadNewsStory(): (storyId: string) => Promise<NewsArticle | null> {
39
43
  return useCallback(async (storyId: string) => sharedService?.loadStory(storyId) ?? null, []);
40
44
  }
45
+
46
+ export function useNewsLoadMore(query: NewsQuery | null | undefined): () => void {
47
+ const key = query ? buildNewsQueryKey(query) : null;
48
+ return useCallback(() => {
49
+ if (!query) return;
50
+ void sharedService?.loadMore(query);
51
+ }, [key]);
52
+ }
53
+
54
+ export function useNewsTableLoadMore(
55
+ query: NewsQuery | null | undefined,
56
+ state: Pick<NewsQueryState, "nextCursor" | "loadingMore">,
57
+ ) {
58
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
59
+ const loadMore = useNewsLoadMore(query);
60
+ const onBodyScrollActivity = useTableLoadMore(
61
+ scrollRef,
62
+ !!state.nextCursor && !state.loadingMore,
63
+ loadMore,
64
+ );
65
+ return { scrollRef, onBodyScrollActivity };
66
+ }
@@ -2,7 +2,7 @@ import { canonicalExchange, normalizeSymbol } from "../utils/exchanges";
2
2
  import type { NewsCapability } from "../capabilities";
3
3
  import type { NewsArticle, NewsFeed, NewsQuery, NewsQueryState } from "./types";
4
4
 
5
- const MAX_ARTICLES = 500;
5
+ export const MAX_ARTICLES = 10_000;
6
6
  export const DEFAULT_GLOBAL_QUERY: NewsQuery = { feed: "latest", limit: MAX_ARTICLES };
7
7
 
8
8
  const FEEDS = new Set<NewsFeed>(["latest", "top", "breaking", "ticker", "sector", "topic"]);
@@ -86,6 +86,8 @@ export function createIdleNewsQueryState(): NewsQueryState {
86
86
  error: null,
87
87
  updatedAt: null,
88
88
  sourceIds: [],
89
+ nextCursor: null,
90
+ loadingMore: false,
89
91
  };
90
92
  }
91
93
 
@@ -204,5 +206,6 @@ export function filterNewsArticlesForQuery(items: NewsArticle[], query: NewsQuer
204
206
  if (query.breaking != null) {
205
207
  filtered = filtered.filter((item) => item.isBreaking === query.breaking);
206
208
  }
207
- return filtered.slice(0, query.limit ?? MAX_ARTICLES);
209
+ // Page size is a fetch hint. Accumulated load-more results keep MAX_ARTICLES.
210
+ return filtered.slice(0, MAX_ARTICLES);
208
211
  }
package/src/news/types.ts CHANGED
@@ -68,10 +68,17 @@ export interface NewsQuery {
68
68
 
69
69
  export type NewsQueryPhase = "idle" | "loading" | "ready" | "refreshing" | "error";
70
70
 
71
+ export interface NewsPage {
72
+ articles: NewsArticle[];
73
+ nextCursor?: string | null;
74
+ }
75
+
71
76
  export interface NewsQueryState {
72
77
  phase: NewsQueryPhase;
73
78
  articles: NewsArticle[];
74
79
  error: string | null;
75
80
  updatedAt: number | null;
76
81
  sourceIds: string[];
82
+ nextCursor: string | null;
83
+ loadingMore: boolean;
77
84
  }
@@ -55,6 +55,35 @@ describe("broker position normalization", () => {
55
55
  markPrice: 250,
56
56
  }));
57
57
  });
58
+
59
+ test("stores shorts as absolute shares and weighted-averages merged short lots", () => {
60
+ const snapshot = normalizeRobinhoodSnapshot(
61
+ { accounts: [{ account_number: "RH-1", account_type: "INDIVIDUAL" }] },
62
+ [{ result: { positions: [
63
+ {
64
+ accountNumber: "RH-1",
65
+ instrument: { symbol: "TSLA" },
66
+ quantity: "-10",
67
+ average_cost: "100",
68
+ market_value: "-800",
69
+ },
70
+ {
71
+ accountNumber: "RH-1",
72
+ instrument: { symbol: "TSLA" },
73
+ quantity: "-5",
74
+ average_cost: "110",
75
+ market_value: "-400",
76
+ },
77
+ ] } }],
78
+ );
79
+
80
+ expect(snapshot.positions).toEqual([expect.objectContaining({
81
+ ticker: "TSLA",
82
+ shares: 15,
83
+ avgCost: 1550 / 15,
84
+ side: "short",
85
+ })]);
86
+ });
58
87
  });
59
88
 
60
89
  describe("broker authentication boundaries", () => {
@@ -108,6 +137,35 @@ describe("broker authentication boundaries", () => {
108
137
  ])).toThrow("read-only get_equity_positions");
109
138
  });
110
139
 
140
+ test("aggregates identical same-account lots instead of keeping only the last row", () => {
141
+ const snapshot = normalizeRobinhoodSnapshot(
142
+ { accounts: [{ account_number: "RH-1", currency: "USD" }] },
143
+ { positions: [
144
+ {
145
+ accountNumber: "RH-1",
146
+ instrument: { symbol: "HOOD" },
147
+ quantity: "2",
148
+ total_cost: "80",
149
+ market_value: "100",
150
+ },
151
+ {
152
+ accountNumber: "RH-1",
153
+ instrument: { symbol: "HOOD" },
154
+ quantity: "3",
155
+ total_cost: "150",
156
+ market_value: "150",
157
+ },
158
+ ] },
159
+ );
160
+
161
+ expect(snapshot.positions).toEqual([expect.objectContaining({
162
+ ticker: "HOOD",
163
+ shares: 5,
164
+ avgCost: 46,
165
+ marketValue: 250,
166
+ })]);
167
+ });
168
+
111
169
  test("SimpleFIN accepts URL-safe setup tokens and rejects non-HTTPS claims", () => {
112
170
  const claimUrl = "https://bridge.simplefin.org/simplefin/claim/demo";
113
171
  const token = Buffer.from(claimUrl).toString("base64url");
@@ -80,11 +80,48 @@ function uniqueSnapshot(accounts: BrokerAccount[], positions: BrokerPosition[]):
80
80
  currency: position.currency,
81
81
  });
82
82
  }
83
- const uniquePositions = [...new Map(positions.map((position) => [
84
- `${position.accountId ?? ""}:${position.ticker}:${position.assetCategory ?? ""}`,
85
- position,
86
- ])).values()];
87
- return { accounts: uniqueAccounts, positions: uniquePositions };
83
+ return { accounts: uniqueAccounts, positions: mergeIdenticalPositions(positions.map(withCanonicalShares)) };
84
+ }
85
+
86
+ function withCanonicalShares(position: BrokerPosition): BrokerPosition {
87
+ const side = position.side ?? (position.shares < 0 ? "short" : "long");
88
+ const shares = Math.abs(position.shares);
89
+ if (shares === position.shares && side === position.side) return position;
90
+ return { ...position, shares, side };
91
+ }
92
+
93
+ function mergeIdenticalPositions(positions: BrokerPosition[]): BrokerPosition[] {
94
+ const merged = new Map<string, BrokerPosition>();
95
+ for (const position of positions) {
96
+ const key = [
97
+ position.accountId ?? "",
98
+ position.ticker,
99
+ position.assetCategory ?? "",
100
+ position.exchange ?? "",
101
+ position.brokerContract?.conId ?? "",
102
+ ].join(":");
103
+ const existing = merged.get(key);
104
+ if (!existing) {
105
+ merged.set(key, position);
106
+ continue;
107
+ }
108
+ const shares = existing.shares + position.shares;
109
+ const existingCost = (existing.avgCost ?? 0) * existing.shares;
110
+ const nextCost = (position.avgCost ?? 0) * position.shares;
111
+ merged.set(key, {
112
+ ...existing,
113
+ shares,
114
+ avgCost: shares !== 0 ? (existingCost + nextCost) / shares : existing.avgCost,
115
+ marketValue: sumOptional(existing.marketValue, position.marketValue),
116
+ unrealizedPnl: sumOptional(existing.unrealizedPnl, position.unrealizedPnl),
117
+ });
118
+ }
119
+ return [...merged.values()];
120
+ }
121
+
122
+ function sumOptional(left?: number, right?: number): number | undefined {
123
+ if (left == null && right == null) return undefined;
124
+ return (left ?? 0) + (right ?? 0);
88
125
  }
89
126
 
90
127
  export function normalizeRobinhoodSnapshot(accountsPayload: unknown, positionsPayload: unknown): BrokerPortfolioSnapshot {