gloomberb 0.5.1 → 0.6.1

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 (506) hide show
  1. package/README.md +91 -11
  2. package/package.json +13 -5
  3. package/src/app.tsx +414 -191
  4. package/src/architecture/import-boundaries.test.ts +118 -0
  5. package/src/assets/gloomberb-logo.png +0 -0
  6. package/src/assets/gloomberb-logo.svg +73 -0
  7. package/src/brokers/account-cache.ts +157 -0
  8. package/src/brokers/profile-form.test.ts +73 -0
  9. package/src/brokers/profile-form.ts +109 -0
  10. package/src/brokers/remote-broker-adapter.ts +91 -0
  11. package/src/brokers/sync-broker-instance.test.ts +124 -1
  12. package/src/brokers/sync-broker-instance.ts +113 -17
  13. package/src/capabilities/factories.ts +100 -0
  14. package/src/capabilities/index.ts +4 -0
  15. package/src/capabilities/registry.test.ts +137 -0
  16. package/src/capabilities/registry.ts +131 -0
  17. package/src/capabilities/service-capabilities.ts +27 -0
  18. package/src/capabilities/types.ts +84 -0
  19. package/src/cli/commands/ticker.ts +11 -3
  20. package/src/cli/context.ts +19 -13
  21. package/src/cli/helpers.ts +1 -0
  22. package/src/cli/types.ts +2 -2
  23. package/src/components/chart/chart-controller.ts +4 -24
  24. package/src/components/chart/chart-data.ts +3 -14
  25. package/src/components/chart/chart-indicator-selector.tsx +28 -6
  26. package/src/components/chart/chart-pane-settings.ts +15 -28
  27. package/src/components/chart/chart-renderer.ts +74 -99
  28. package/src/components/chart/chart-resolution.ts +10 -10
  29. package/src/components/chart/chart-scroll.test.ts +6 -5
  30. package/src/components/chart/chart-scroll.ts +2 -2
  31. package/src/components/chart/chart-types.ts +0 -1
  32. package/src/components/chart/chart-viewport.ts +2 -1
  33. package/src/components/chart/chart.test.ts +37 -0
  34. package/src/components/chart/comparison-chart-data.ts +3 -17
  35. package/src/components/chart/comparison-chart-renderer.test.ts +27 -4
  36. package/src/components/chart/comparison-chart-renderer.ts +1 -1
  37. package/src/components/chart/comparison-stock-chart.tsx +318 -158
  38. package/src/components/chart/cursor-motion.ts +3 -2
  39. package/src/components/chart/indicators/bands.test.ts +0 -14
  40. package/src/components/chart/indicators/moving-averages.test.ts +0 -15
  41. package/src/components/chart/indicators/options.ts +3 -3
  42. package/src/components/chart/indicators/oscillators.test.ts +0 -21
  43. package/src/components/chart/indicators/types.ts +1 -2
  44. package/src/components/chart/native/chart-rasterizer.ts +37 -44
  45. package/src/components/chart/native/kitty-adapter.ts +2 -5
  46. package/src/components/chart/native/kitty-manager.ts +1 -1
  47. package/src/components/chart/native/kitty-protocol.ts +0 -1
  48. package/src/components/chart/native/kitty-support.ts +1 -1
  49. package/src/components/chart/native/kitty.test.ts +53 -13
  50. package/src/components/chart/native/renderer-selection.ts +1 -1
  51. package/src/components/chart/native/surface-manager.ts +1 -1
  52. package/src/components/chart/native/surface-visibility.ts +48 -0
  53. package/src/components/chart/static-chart-surface.test.tsx +51 -0
  54. package/src/components/chart/static-chart-surface.tsx +191 -0
  55. package/src/components/chart/stock-chart-renderer-switch.test.tsx +242 -35
  56. package/src/components/chart/stock-chart.tsx +452 -191
  57. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  58. package/src/components/command-bar/command-bar.test.tsx +269 -20
  59. package/src/components/command-bar/command-bar.tsx +830 -353
  60. package/src/components/command-bar/command-registry.ts +10 -20
  61. package/src/components/command-bar/helpers.test.ts +1 -1
  62. package/src/components/command-bar/helpers.ts +9 -11
  63. package/src/components/command-bar/pane-template-display.ts +4 -8
  64. package/src/components/command-bar/root-shortcuts.ts +12 -11
  65. package/src/components/command-bar/view-model.test.ts +8 -42
  66. package/src/components/command-bar/view-model.ts +8 -36
  67. package/src/components/command-bar/workflow-ops.test.ts +76 -2
  68. package/src/components/command-bar/workflow-ops.ts +40 -9
  69. package/src/components/command-bar/workflow-types.ts +2 -1
  70. package/src/components/data-table-stack-view.test.tsx +206 -0
  71. package/src/components/data-table-stack-view.tsx +152 -0
  72. package/src/components/data-table-view.test.tsx +188 -0
  73. package/src/components/data-table-view.tsx +279 -0
  74. package/src/components/feed-data-table-stack-view.test.tsx +225 -0
  75. package/src/components/feed-data-table-stack-view.tsx +365 -0
  76. package/src/components/index.ts +26 -31
  77. package/src/components/layout/detached-pane-shell.tsx +185 -0
  78. package/src/components/layout/floating-pane.tsx +87 -14
  79. package/src/components/layout/header.tsx +139 -49
  80. package/src/components/layout/pane-content.tsx +42 -0
  81. package/src/components/layout/pane-footer.test.tsx +195 -0
  82. package/src/components/layout/pane-footer.tsx +425 -0
  83. package/src/components/layout/pane-header.test.tsx +39 -0
  84. package/src/components/layout/pane-header.tsx +192 -14
  85. package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
  86. package/src/components/layout/pane-sizing.ts +9 -10
  87. package/src/components/layout/pane-title.ts +35 -0
  88. package/src/components/layout/pane.tsx +52 -11
  89. package/src/components/layout/shell.test.tsx +315 -107
  90. package/src/components/layout/shell.tsx +1010 -368
  91. package/src/components/layout/status-bar.test.tsx +17 -10
  92. package/src/components/layout/status-bar.tsx +367 -100
  93. package/src/components/layout/titlebar-overlay.ts +2 -0
  94. package/src/components/markdown-editor.tsx +29 -14
  95. package/src/components/markdown-text.tsx +25 -24
  96. package/src/components/onboarding/onboarding-wizard.test.tsx +8 -3
  97. package/src/components/onboarding/onboarding-wizard.tsx +347 -473
  98. package/src/components/pane-settings-dialog.tsx +521 -89
  99. package/src/components/pane-template-wizard.test.tsx +1 -1
  100. package/src/components/pane-template-wizard.tsx +38 -37
  101. package/src/components/price-selector-dialog.tsx +22 -21
  102. package/src/components/ticker-badge-list.tsx +53 -0
  103. package/src/components/ticker-badge-text.test.tsx +44 -1
  104. package/src/components/ticker-badge-text.tsx +78 -38
  105. package/src/components/ticker-badge.tsx +28 -7
  106. package/src/components/ticker-list-table-view.tsx +248 -0
  107. package/src/components/ticker-list-table.test.tsx +47 -1
  108. package/src/components/ticker-list-table.tsx +212 -41
  109. package/src/components/toggle-list.tsx +114 -8
  110. package/src/components/ui/button.tsx +24 -16
  111. package/src/components/ui/choice-dialog.tsx +89 -0
  112. package/src/components/ui/data-table.tsx +313 -114
  113. package/src/components/ui/external-link.tsx +52 -13
  114. package/src/components/ui/fields.tsx +44 -30
  115. package/src/components/ui/frame.tsx +24 -57
  116. package/src/components/ui/index.ts +15 -50
  117. package/src/components/ui/list-view.tsx +65 -25
  118. package/src/components/ui/loading.tsx +5 -60
  119. package/src/components/ui/message-composer.test.tsx +81 -0
  120. package/src/components/ui/message-composer.tsx +139 -0
  121. package/src/components/ui/multi-select-dialog.tsx +100 -32
  122. package/src/components/ui/multi-select.ts +112 -0
  123. package/src/components/ui/native-select.tsx +120 -0
  124. package/src/components/ui/page-stack-view.tsx +49 -19
  125. package/src/components/ui/remote-image.tsx +50 -0
  126. package/src/components/ui/row-value-cache.test.ts +17 -0
  127. package/src/components/ui/row-value-cache.ts +41 -0
  128. package/src/components/ui/shortcut-hint.tsx +55 -0
  129. package/src/components/ui/status.tsx +12 -67
  130. package/src/components/ui/table-layout.ts +59 -0
  131. package/src/components/ui/tabs.tsx +249 -27
  132. package/src/components/ui/toggle.tsx +19 -127
  133. package/src/components/ui/ui.test.tsx +266 -53
  134. package/src/components/use-double-click-activation.ts +7 -2
  135. package/src/components/use-external-link-footer.ts +79 -0
  136. package/src/core/app-services.ts +95 -0
  137. package/src/core/state/app-state.ts +886 -0
  138. package/src/{state → core/state}/session-persistence.ts +7 -6
  139. package/src/data/config-layout.ts +225 -0
  140. package/src/data/config-store-node.ts +390 -0
  141. package/src/data/config-store.test.ts +72 -0
  142. package/src/data/config-store.ts +35 -478
  143. package/src/data/sqlite-json.ts +2 -5
  144. package/src/data/sqlite-retry.ts +1 -0
  145. package/src/index.tsx +2 -68
  146. package/src/market-data/coordinator-subscriptions.test.ts +125 -0
  147. package/src/market-data/coordinator.test.ts +73 -4
  148. package/src/market-data/coordinator.ts +190 -24
  149. package/src/market-data/hooks.test.tsx +1 -1
  150. package/src/market-data/hooks.tsx +109 -87
  151. package/src/market-data/query-store.ts +3 -3
  152. package/src/market-data/request-types.test.ts +0 -14
  153. package/src/market-data/result-types.ts +1 -0
  154. package/src/news/aggregator.test.ts +267 -0
  155. package/src/news/aggregator.ts +452 -0
  156. package/src/news/hooks.ts +39 -0
  157. package/src/news/ticker-symbols.ts +21 -0
  158. package/src/news/types.ts +77 -0
  159. package/src/notifications/app-notifier.ts +21 -10
  160. package/src/pane-settings.test.ts +0 -2
  161. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +124 -134
  162. package/src/plugins/builtin/ai/gloomberb-cli.ts +14 -13
  163. package/src/plugins/builtin/ai/providers.ts +1 -9
  164. package/src/plugins/builtin/ai/runner.ts +41 -5
  165. package/src/plugins/builtin/ai/screener-pane.test.tsx +37 -55
  166. package/src/plugins/builtin/ai/screener-pane.tsx +231 -276
  167. package/src/plugins/builtin/ai/settings.ts +0 -1
  168. package/src/plugins/builtin/ai/utils.ts +1 -71
  169. package/src/plugins/builtin/alerts/alert-engine.test.ts +1 -38
  170. package/src/plugins/builtin/alerts/index.test.tsx +61 -82
  171. package/src/plugins/builtin/alerts/index.tsx +263 -155
  172. package/src/plugins/builtin/alerts/types.ts +7 -0
  173. package/src/plugins/builtin/analytics/index.test.tsx +13 -8
  174. package/src/plugins/builtin/analytics/index.tsx +78 -120
  175. package/src/plugins/builtin/analytics/metrics.test.ts +0 -4
  176. package/src/plugins/builtin/ask-ai.test.tsx +55 -54
  177. package/src/plugins/builtin/broker-manager/index.test.tsx +168 -0
  178. package/src/plugins/builtin/broker-manager/index.tsx +759 -0
  179. package/src/plugins/builtin/broker-manager/model.test.ts +107 -0
  180. package/src/plugins/builtin/broker-manager/model.ts +142 -0
  181. package/src/plugins/builtin/chat-controller.test.ts +129 -0
  182. package/src/plugins/builtin/chat-controller.ts +85 -14
  183. package/src/plugins/builtin/chat.test.tsx +283 -36
  184. package/src/plugins/builtin/chat.tsx +363 -170
  185. package/src/plugins/builtin/cloud-auth-actions.tsx +72 -0
  186. package/src/plugins/builtin/cloud-tweets.tsx +896 -0
  187. package/src/plugins/builtin/comparison-chart.test.tsx +46 -57
  188. package/src/plugins/builtin/comparison-chart.tsx +11 -11
  189. package/src/plugins/builtin/correlation/compute.test.ts +0 -17
  190. package/src/plugins/builtin/correlation/index.tsx +51 -46
  191. package/src/plugins/builtin/correlation/settings.ts +1 -2
  192. package/src/plugins/builtin/debug.tsx +99 -70
  193. package/src/plugins/builtin/earnings/earnings-cache.test.ts +2 -2
  194. package/src/plugins/builtin/earnings/earnings-cache.ts +12 -3
  195. package/src/plugins/builtin/earnings/earnings-data.test.ts +42 -11
  196. package/src/plugins/builtin/earnings/earnings-data.ts +70 -5
  197. package/src/plugins/builtin/earnings/index.tsx +187 -94
  198. package/src/plugins/builtin/econ/calendar-source.ts +9 -116
  199. package/src/plugins/builtin/econ/fred-client.test.ts +1 -14
  200. package/src/plugins/builtin/econ/fred-series-map.ts +1 -0
  201. package/src/plugins/builtin/econ/index.tsx +236 -421
  202. package/src/plugins/builtin/econ/types.ts +1 -0
  203. package/src/plugins/builtin/fx-matrix/index.tsx +43 -45
  204. package/src/plugins/builtin/fx-matrix/pairs.ts +1 -0
  205. package/src/plugins/builtin/help.test.tsx +120 -60
  206. package/src/plugins/builtin/help.tsx +266 -82
  207. package/src/plugins/builtin/holders/index.test.ts +45 -0
  208. package/src/plugins/builtin/holders/index.tsx +949 -0
  209. package/src/plugins/builtin/insider/index.tsx +207 -253
  210. package/src/plugins/builtin/insider/insider-data.test.ts +1 -11
  211. package/src/plugins/builtin/insider/insider-data.ts +1 -0
  212. package/src/plugins/builtin/layout-manager.tsx +12 -40
  213. package/src/plugins/builtin/market-movers/index.tsx +70 -144
  214. package/src/plugins/builtin/market-movers/screener.test.ts +0 -18
  215. package/src/plugins/builtin/news-wire/breaking-notifications.test.ts +98 -0
  216. package/src/plugins/builtin/news-wire/breaking-notifications.ts +110 -0
  217. package/src/plugins/builtin/news-wire/breaking-pane.tsx +157 -0
  218. package/src/plugins/builtin/news-wire/categories.ts +114 -0
  219. package/src/plugins/builtin/news-wire/default-feeds.ts +3 -0
  220. package/src/plugins/builtin/news-wire/digest-store.ts +44 -0
  221. package/src/plugins/builtin/news-wire/feed-config.test.ts +90 -0
  222. package/src/plugins/builtin/news-wire/feed-config.ts +216 -0
  223. package/src/plugins/builtin/news-wire/feed-pane.tsx +21 -0
  224. package/src/plugins/builtin/news-wire/hash.ts +9 -0
  225. package/src/plugins/builtin/news-wire/index.tsx +92 -0
  226. package/src/plugins/builtin/news-wire/industry-pane.tsx +140 -0
  227. package/src/plugins/builtin/news-wire/news-detail-view.tsx +256 -0
  228. package/src/plugins/builtin/news-wire/news-footer.ts +28 -0
  229. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +94 -0
  230. package/src/plugins/builtin/news-wire/news-query-presets.ts +40 -0
  231. package/src/plugins/builtin/news-wire/news-table.test.tsx +206 -0
  232. package/src/plugins/builtin/news-wire/news-table.tsx +296 -0
  233. package/src/plugins/builtin/news-wire/persisted-articles.ts +92 -0
  234. package/src/plugins/builtin/news-wire/read-state.test.ts +30 -0
  235. package/src/plugins/builtin/news-wire/read-state.ts +76 -0
  236. package/src/plugins/builtin/news-wire/rss-parser.test.ts +184 -0
  237. package/src/plugins/builtin/news-wire/rss-parser.ts +213 -0
  238. package/src/plugins/builtin/news-wire/rss-source.test.ts +159 -0
  239. package/src/plugins/builtin/news-wire/rss-source.ts +181 -0
  240. package/src/plugins/builtin/news-wire/top-pane.tsx +21 -0
  241. package/src/plugins/builtin/news.tsx +98 -25
  242. package/src/plugins/builtin/notes-files.ts +51 -10
  243. package/src/plugins/builtin/notes.tsx +332 -131
  244. package/src/plugins/builtin/options.test.tsx +185 -0
  245. package/src/plugins/builtin/options.tsx +346 -206
  246. package/src/plugins/builtin/plugin-groups.ts +92 -0
  247. package/src/plugins/builtin/portfolio-list/header.tsx +72 -37
  248. package/src/plugins/builtin/portfolio-list/index.tsx +25 -42
  249. package/src/plugins/builtin/portfolio-list/metrics.ts +1 -1
  250. package/src/plugins/builtin/portfolio-list/pane.tsx +246 -164
  251. package/src/plugins/builtin/portfolio-list/settings.ts +19 -51
  252. package/src/plugins/builtin/portfolio-list/summary.tsx +21 -16
  253. package/src/plugins/builtin/portfolio-list/table.tsx +66 -25
  254. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +1 -1
  255. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +1 -1
  256. package/src/plugins/builtin/portfolio-list.test.ts +60 -1
  257. package/src/plugins/builtin/portfolio-list.test.tsx +122 -55
  258. package/src/plugins/builtin/quote-monitor.test.tsx +2 -27
  259. package/src/plugins/builtin/research/index.tsx +724 -0
  260. package/src/plugins/builtin/sec.tsx +67 -19
  261. package/src/plugins/builtin/sectors/index.tsx +42 -80
  262. package/src/plugins/builtin/sectors/sector-data.ts +1 -0
  263. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +25 -39
  264. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +108 -58
  265. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +383 -271
  266. package/src/plugins/builtin/ticker-detail/pane.tsx +143 -49
  267. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +43 -27
  268. package/src/plugins/builtin/ticker-detail/settings.ts +3 -6
  269. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +82 -10
  270. package/src/plugins/builtin/ticker-detail.test.tsx +258 -33
  271. package/src/plugins/builtin/ticker-surface.ts +68 -0
  272. package/src/plugins/builtin/world-indices/index.tsx +44 -82
  273. package/src/plugins/builtin/world-indices/indices.ts +1 -0
  274. package/src/plugins/builtin/yahoo.ts +55 -1
  275. package/src/plugins/builtin/yield-curve/index.tsx +87 -145
  276. package/src/plugins/builtin/yield-curve/treasury-data.ts +6 -77
  277. package/src/plugins/catalog-ui.test.ts +24 -0
  278. package/src/plugins/catalog-ui.ts +44 -0
  279. package/src/plugins/catalog.ts +2 -50
  280. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  281. package/src/plugins/ibkr/account-cache.test.ts +15 -14
  282. package/src/plugins/ibkr/account-cache.ts +15 -120
  283. package/src/plugins/ibkr/broker-adapter.ts +67 -0
  284. package/src/plugins/ibkr/config.test.ts +0 -18
  285. package/src/plugins/ibkr/dialogs.tsx +14 -56
  286. package/src/plugins/ibkr/flex.test.ts +57 -2
  287. package/src/plugins/ibkr/flex.ts +116 -12
  288. package/src/plugins/ibkr/gateway-helpers.ts +8 -3
  289. package/src/plugins/ibkr/gateway-service-native.ts +2381 -0
  290. package/src/plugins/ibkr/gateway-service.test.ts +47 -9
  291. package/src/plugins/ibkr/gateway-service.ts +264 -2235
  292. package/src/plugins/ibkr/gateway-types.ts +27 -0
  293. package/src/plugins/ibkr/index.test.tsx +3 -11
  294. package/src/plugins/ibkr/index.tsx +1 -0
  295. package/src/plugins/ibkr/price-normalization.test.ts +1 -6
  296. package/src/plugins/ibkr/price-normalization.ts +1 -0
  297. package/src/plugins/ibkr/trade-badge.tsx +6 -5
  298. package/src/plugins/ibkr/trade-tab.test.tsx +55 -3
  299. package/src/plugins/ibkr/trade-tab.tsx +166 -117
  300. package/src/plugins/ibkr/trading-pane.tsx +117 -83
  301. package/src/plugins/pane-manager.test.ts +3 -1
  302. package/src/plugins/pane-manager.ts +93 -19
  303. package/src/plugins/plugin-runtime.test.tsx +176 -74
  304. package/src/plugins/plugin-runtime.tsx +228 -10
  305. package/src/plugins/prediction-markets/cache.ts +49 -0
  306. package/src/plugins/prediction-markets/categories.ts +0 -10
  307. package/src/plugins/prediction-markets/chart.test.tsx +1 -1
  308. package/src/plugins/prediction-markets/chart.tsx +21 -20
  309. package/src/plugins/prediction-markets/cli.ts +1 -0
  310. package/src/plugins/prediction-markets/controller-data.ts +135 -89
  311. package/src/plugins/prediction-markets/controller.ts +37 -108
  312. package/src/plugins/prediction-markets/detail/book.tsx +23 -22
  313. package/src/plugins/prediction-markets/detail/outcomes.tsx +33 -32
  314. package/src/plugins/prediction-markets/detail/overview.tsx +17 -12
  315. package/src/plugins/prediction-markets/detail/pane.tsx +59 -45
  316. package/src/plugins/prediction-markets/detail/rules.tsx +22 -7
  317. package/src/plugins/prediction-markets/detail/shared.tsx +3 -2
  318. package/src/plugins/prediction-markets/detail/trades.tsx +12 -11
  319. package/src/plugins/prediction-markets/detail.test.tsx +1 -1
  320. package/src/plugins/prediction-markets/keyboard.ts +1 -0
  321. package/src/plugins/prediction-markets/launch.test.ts +1 -1
  322. package/src/plugins/prediction-markets/launch.ts +1 -1
  323. package/src/plugins/prediction-markets/metrics.ts +25 -12
  324. package/src/plugins/prediction-markets/pane.test.tsx +16 -7
  325. package/src/plugins/prediction-markets/pane.tsx +180 -82
  326. package/src/plugins/prediction-markets/plugin.test.tsx +37 -45
  327. package/src/plugins/prediction-markets/services/fetch.ts +27 -10
  328. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +20 -3
  329. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  330. package/src/plugins/prediction-markets/settings.ts +0 -5
  331. package/src/plugins/prediction-markets/test-helpers.tsx +19 -44
  332. package/src/plugins/registry.test.ts +307 -0
  333. package/src/plugins/registry.ts +293 -82
  334. package/src/react/input.ts +61 -0
  335. package/src/react/plugins/plugin-slot.tsx +21 -0
  336. package/src/react/use-raf-callback.ts +44 -0
  337. package/src/renderers/electrobun/bun/application-menu-click.test.ts +41 -0
  338. package/src/renderers/electrobun/bun/application-menu-click.ts +55 -0
  339. package/src/renderers/electrobun/bun/application-menu.ts +118 -0
  340. package/src/renderers/electrobun/bun/context-menu-click.test.ts +67 -0
  341. package/src/renderers/electrobun/bun/context-menu-click.ts +61 -0
  342. package/src/renderers/electrobun/bun/context-menu-normalize.test.ts +52 -0
  343. package/src/renderers/electrobun/bun/context-menu-normalize.ts +67 -0
  344. package/src/renderers/electrobun/bun/core-capabilities.ts +328 -0
  345. package/src/renderers/electrobun/bun/desktop-workspace.test.ts +63 -0
  346. package/src/renderers/electrobun/bun/desktop-workspace.ts +162 -0
  347. package/src/renderers/electrobun/bun/index.ts +1149 -0
  348. package/src/renderers/electrobun/bun/window-frame.test.ts +40 -0
  349. package/src/renderers/electrobun/bun/window-frame.ts +46 -0
  350. package/src/renderers/electrobun/shared/protocol.ts +72 -0
  351. package/src/renderers/electrobun/view/ai-host.ts +93 -0
  352. package/src/renderers/electrobun/view/app-services.ts +348 -0
  353. package/src/renderers/electrobun/view/application-menu-bridge.ts +12 -0
  354. package/src/renderers/electrobun/view/backend-rpc.ts +173 -0
  355. package/src/renderers/electrobun/view/broker-remote-client.ts +154 -0
  356. package/src/renderers/electrobun/view/config-host.ts +35 -0
  357. package/src/renderers/electrobun/view/context-menu.test.ts +140 -0
  358. package/src/renderers/electrobun/view/context-menu.ts +164 -0
  359. package/src/renderers/electrobun/view/data-table.tsx +773 -0
  360. package/src/renderers/electrobun/view/desktop-controls.tsx +651 -0
  361. package/src/renderers/electrobun/view/desktop-window-bridge.ts +59 -0
  362. package/src/renderers/electrobun/view/desktop-window-snapshot.test.ts +57 -0
  363. package/src/renderers/electrobun/view/desktop-window-snapshot.ts +41 -0
  364. package/src/renderers/electrobun/view/dialog-host.tsx +62 -0
  365. package/src/renderers/electrobun/view/http-fetch.ts +113 -0
  366. package/src/renderers/electrobun/view/input-host.tsx +112 -0
  367. package/src/renderers/electrobun/view/key-event.test.ts +34 -0
  368. package/src/renderers/electrobun/view/key-event.ts +134 -0
  369. package/src/renderers/electrobun/view/main.tsx +104 -0
  370. package/src/renderers/electrobun/view/native-renderer.ts +110 -0
  371. package/src/renderers/electrobun/view/native-stubs/chart-kitty-support.ts +9 -0
  372. package/src/renderers/electrobun/view/native-stubs/chart-surface-manager.ts +25 -0
  373. package/src/renderers/electrobun/view/native-stubs/chart-surface-sync.ts +1 -0
  374. package/src/renderers/electrobun/view/notes-files.ts +56 -0
  375. package/src/renderers/electrobun/view/resource-store.test.ts +44 -0
  376. package/src/renderers/electrobun/view/resource-store.ts +106 -0
  377. package/src/renderers/electrobun/view/rpc-codec.ts +42 -0
  378. package/src/renderers/electrobun/view/scrollbar-activity.ts +23 -0
  379. package/src/renderers/electrobun/view/toast-host.tsx +65 -0
  380. package/src/renderers/electrobun/view/ui-host.tsx +1509 -0
  381. package/src/renderers/electrobun/view/update-host.ts +38 -0
  382. package/src/renderers/opentui/dialog-host.test.tsx +44 -0
  383. package/src/renderers/opentui/dialog-host.tsx +56 -0
  384. package/src/renderers/opentui/host.tsx +140 -0
  385. package/src/renderers/opentui/image-loader.test.ts +25 -0
  386. package/src/renderers/opentui/image-loader.ts +105 -0
  387. package/src/renderers/opentui/image-surface.tsx +266 -0
  388. package/src/renderers/opentui/input-host.tsx +63 -0
  389. package/src/renderers/opentui/start.tsx +71 -0
  390. package/src/renderers/opentui/test-utils.tsx +76 -0
  391. package/src/renderers/opentui/toast-host.tsx +10 -0
  392. package/src/renderers/opentui/ui-host.tsx +98 -0
  393. package/src/sources/gloomberb-cloud.test.ts +288 -17
  394. package/src/sources/gloomberb-cloud.ts +176 -9
  395. package/src/sources/provider-errors.ts +1 -0
  396. package/src/sources/provider-router.test.ts +220 -81
  397. package/src/sources/provider-router.ts +294 -111
  398. package/src/sources/sec-edgar.ts +19 -6
  399. package/src/sources/yahoo-finance.test.ts +0 -5
  400. package/src/sources/yahoo-finance.ts +420 -11
  401. package/src/state/app-activity.ts +7 -7
  402. package/src/state/app-bootstrap.ts +114 -40
  403. package/src/state/app-context-selector.test.tsx +70 -1
  404. package/src/state/app-context.test.ts +71 -3
  405. package/src/state/app-context.test.tsx +14 -7
  406. package/src/state/app-context.tsx +209 -869
  407. package/src/state/config-save-scheduler.ts +35 -0
  408. package/src/state/pane-scroll-registry.tsx +254 -0
  409. package/src/state/persist-scheduler.test.ts +67 -0
  410. package/src/state/persist-scheduler.ts +69 -0
  411. package/src/state/selectors-ui.ts +55 -0
  412. package/src/state/selectors.ts +15 -71
  413. package/src/state/session-persistence.test.ts +1 -1
  414. package/src/state/ticker-refresh-queue.ts +52 -3
  415. package/src/state/use-inline-tickers.ts +18 -16
  416. package/src/state/use-quote-streaming.test.ts +0 -6
  417. package/src/state/use-quote-streaming.test.tsx +1 -1
  418. package/src/state/use-quote-streaming.ts +3 -2
  419. package/src/state/use-session-snapshot.ts +32 -17
  420. package/src/test-support/plugin-runtime.ts +149 -0
  421. package/src/theme/color-utils.ts +1 -0
  422. package/src/theme/colors.ts +16 -0
  423. package/src/types/broker.ts +16 -1
  424. package/src/types/capability-route-source.ts +23 -0
  425. package/src/types/config.test.ts +23 -0
  426. package/src/types/config.ts +145 -10
  427. package/src/types/context-menu.ts +95 -0
  428. package/src/types/data-provider.ts +34 -3
  429. package/src/types/desktop-menu.ts +13 -0
  430. package/src/types/desktop-window.ts +29 -0
  431. package/src/types/financials.ts +123 -2
  432. package/src/types/instrument.ts +1 -0
  433. package/src/types/news-source.ts +9 -0
  434. package/src/types/persistence.ts +1 -0
  435. package/src/types/plugin.ts +32 -8
  436. package/src/ui/ascii-font.ts +85 -0
  437. package/src/ui/context-menu.test.ts +94 -0
  438. package/src/ui/context-menu.tsx +433 -0
  439. package/src/ui/dialog.tsx +55 -0
  440. package/src/ui/host.tsx +364 -0
  441. package/src/ui/index.tsx +134 -0
  442. package/src/ui/toast.tsx +40 -0
  443. package/src/updater.test.ts +69 -0
  444. package/src/updater.ts +61 -18
  445. package/src/utils/api-client.test.ts +99 -9
  446. package/src/utils/api-client.ts +378 -18
  447. package/src/utils/back-navigation.ts +6 -0
  448. package/src/utils/cli-output.ts +1 -0
  449. package/src/utils/currency-units.test.ts +1 -6
  450. package/src/utils/currency-units.ts +1 -0
  451. package/src/utils/debug-log.ts +68 -0
  452. package/src/utils/dom-screenshot.ts +326 -0
  453. package/src/utils/exchange-rate-map.ts +1 -0
  454. package/src/utils/exchanges.ts +214 -0
  455. package/src/utils/format.test.ts +7 -0
  456. package/src/utils/format.ts +1 -0
  457. package/src/utils/fuzzy-search.ts +1 -0
  458. package/src/utils/html-entities.test.ts +8 -0
  459. package/src/utils/html-entities.ts +32 -0
  460. package/src/utils/http-transport.ts +11 -0
  461. package/src/utils/main-thread-monitor.ts +127 -0
  462. package/src/utils/perf-marks.ts +54 -0
  463. package/src/utils/quote-freshness.ts +27 -50
  464. package/src/utils/quote-resolution.test.ts +5 -5
  465. package/src/utils/quote-resolution.ts +0 -1
  466. package/src/utils/quote-time.ts +1 -5
  467. package/src/utils/sec.test.ts +0 -12
  468. package/src/utils/selection-clipboard.ts +14 -6
  469. package/src/utils/shortcut-labels.test.ts +20 -0
  470. package/src/utils/shortcut-labels.ts +67 -0
  471. package/src/utils/slugify.ts +1 -0
  472. package/src/utils/terminal-input-reset.ts +1 -0
  473. package/src/utils/text-wrap.ts +82 -0
  474. package/src/utils/throttled-fetch.test.ts +19 -0
  475. package/src/utils/throttled-fetch.ts +11 -9
  476. package/src/utils/ticker-list.test.ts +1 -7
  477. package/src/utils/ticker-list.ts +1 -0
  478. package/src/utils/timestamp.ts +29 -0
  479. package/src/utils/tweet-text.test.ts +13 -0
  480. package/src/utils/tweet-text.ts +18 -0
  481. package/src/utils/url.ts +11 -0
  482. package/src/version.ts +1 -1
  483. package/src/components/chart/chart-control-hint.tsx +0 -39
  484. package/src/components/detail-feed-view.test.tsx +0 -180
  485. package/src/components/detail-feed-view.tsx +0 -531
  486. package/src/components/layout/pixel-mouse-mode.test.ts +0 -122
  487. package/src/components/layout/pixel-mouse.test.ts +0 -40
  488. package/src/components/spinner.tsx +0 -2
  489. package/src/components/tab-bar.tsx +0 -17
  490. package/src/components/ui/multi-select-chips.tsx +0 -69
  491. package/src/plugins/builtin/ai/index.test.tsx +0 -139
  492. package/src/plugins/builtin/ask-ai.tsx +0 -9
  493. package/src/plugins/builtin/correlation/plugin.test.ts +0 -44
  494. package/src/plugins/builtin/correlation/settings.test.ts +0 -34
  495. package/src/plugins/builtin/econ/calendar-source.test.ts +0 -219
  496. package/src/plugins/builtin/econ/fred-client.ts +0 -79
  497. package/src/plugins/builtin/fred-settings.ts +0 -10
  498. package/src/plugins/builtin/options-availability.test.tsx +0 -179
  499. package/src/plugins/builtin/options-availability.ts +0 -158
  500. package/src/plugins/builtin/portfolio-list/metrics.test.ts +0 -61
  501. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +0 -212
  502. package/src/plugins/prediction-markets/status.ts +0 -24
  503. package/src/plugins/prediction-markets/table.tsx +0 -87
  504. package/src/utils/quote-time.test.ts +0 -22
  505. package/src/utils/selection-clipboard.test.ts +0 -43
  506. package/src/utils/terminal-input-reset.test.ts +0 -50
package/src/index.tsx CHANGED
@@ -1,72 +1,6 @@
1
- import { createCliRenderer } from "@opentui/core";
2
- import { createRoot } from "@opentui/react";
3
- import { App } from "./app";
4
- import { getDataDir, initDataDir } from "./data/config-store";
5
- import { join } from "path";
6
- import { existsSync, mkdirSync } from "fs";
7
- import { dispatchCli } from "./cli/index";
8
- import { loadExternalPlugins } from "./plugins/loader";
9
- import { debugLog } from "./utils/debug-log";
10
- import { resetTerminalInputState } from "./utils/terminal-input-reset";
1
+ import { startOpenTuiApp } from "./renderers/opentui/start";
11
2
 
12
- async function main() {
13
- // Intercept console.log/warn/error to capture in debug log
14
- debugLog.interceptConsole();
15
-
16
- const appLog = debugLog.createLogger("app");
17
- appLog.info("Gloomberb starting");
18
-
19
- // Handle CLI subcommands (install, remove, update, plugins)
20
- const cliArgs = process.argv.slice(2);
21
- const externalPlugins = await loadExternalPlugins();
22
- let cliLaunchRequest = null;
23
- if (cliArgs.length > 0) {
24
- const dispatchResult = await dispatchCli(cliArgs, { externalPlugins });
25
- if (dispatchResult.kind === "handled") return;
26
- if (dispatchResult.kind === "launch-ui") {
27
- cliLaunchRequest = dispatchResult.request;
28
- }
29
- }
30
-
31
- // Determine data directory
32
- let dataDir = await getDataDir();
33
-
34
- if (!dataDir) {
35
- // First run - use default location
36
- dataDir = join(process.env.HOME || "~", ".gloomberb");
37
- }
38
-
39
- // Ensure data dir exists
40
- if (!existsSync(dataDir)) {
41
- mkdirSync(dataDir, { recursive: true });
42
- }
43
-
44
- // Load or create config
45
- const config = await initDataDir(dataDir);
46
-
47
- // `bun run --watch` does not guarantee the previous run's renderer can cleanly
48
- // restore mouse/raw terminal modes before the next run starts.
49
- resetTerminalInputState();
50
-
51
- // Create renderer
52
- const renderer = await createCliRenderer({
53
- exitOnCtrlC: true,
54
- backgroundColor: "#000000",
55
- enableMouseMovement: true,
56
- });
57
-
58
- // Render app
59
- createRoot(renderer).render(
60
- <App
61
- config={config}
62
- renderer={renderer}
63
- externalPlugins={externalPlugins}
64
- cliLaunchRequest={cliLaunchRequest}
65
- />
66
- );
67
- }
68
-
69
- main().catch((err) => {
3
+ startOpenTuiApp().catch((err) => {
70
4
  console.error("Fatal error:", err);
71
5
  process.exitCode = 1;
72
6
  });
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MarketDataCoordinator } from "./coordinator";
3
+ import { buildQuoteKey } from "./selectors";
4
+ import { createTestDataProvider } from "../test-support/data-provider";
5
+ import type { Quote } from "../types/financials";
6
+ import type { DataProvider, QuoteSubscriptionTarget } from "../types/data-provider";
7
+
8
+ function createProvider(): {
9
+ provider: DataProvider;
10
+ emitQuote: (target: QuoteSubscriptionTarget, quote: Quote) => void;
11
+ } {
12
+ let onQuote: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
13
+ const provider = createTestDataProvider({
14
+ id: "test-provider",
15
+ subscribeQuotes: (_targets, handler) => {
16
+ onQuote = handler;
17
+ return () => {};
18
+ },
19
+ });
20
+ return {
21
+ provider,
22
+ emitQuote(target, quote) {
23
+ if (!onQuote) throw new Error("subscription was not registered");
24
+ onQuote(target, quote);
25
+ },
26
+ };
27
+ }
28
+
29
+ function quote(symbol: string, price: number, overrides: Partial<Quote> = {}): Quote {
30
+ return {
31
+ symbol,
32
+ price,
33
+ currency: "USD",
34
+ change: 0,
35
+ changePercent: 0,
36
+ lastUpdated: Date.now(),
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ async function flushCoordinator(): Promise<void> {
42
+ await Promise.resolve();
43
+ }
44
+
45
+ describe("MarketDataCoordinator key subscriptions", () => {
46
+ test("notifies listeners for changed keys only", async () => {
47
+ const { provider, emitQuote } = createProvider();
48
+ const coordinator = new MarketDataCoordinator(provider);
49
+ const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
50
+ const msft = { symbol: "MSFT", exchange: "NASDAQ" };
51
+ let aaplCalls = 0;
52
+ let msftCalls = 0;
53
+
54
+ coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { aaplCalls += 1; });
55
+ coordinator.subscribeKeys([buildQuoteKey(msft)], () => { msftCalls += 1; });
56
+ coordinator.subscribeQuotes([{ instrument: aapl }, { instrument: msft }]);
57
+
58
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100));
59
+ await flushCoordinator();
60
+
61
+ expect(aaplCalls).toBe(1);
62
+ expect(msftCalls).toBe(0);
63
+ });
64
+
65
+ test("dedupes listeners subscribed to multiple changed keys", async () => {
66
+ const { provider, emitQuote } = createProvider();
67
+ const coordinator = new MarketDataCoordinator(provider);
68
+ const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
69
+ const msft = { symbol: "MSFT", exchange: "NASDAQ" };
70
+ let calls = 0;
71
+
72
+ coordinator.subscribeKeys([buildQuoteKey(aapl), buildQuoteKey(msft)], () => { calls += 1; });
73
+ coordinator.subscribeQuotes([{ instrument: aapl }, { instrument: msft }]);
74
+
75
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100));
76
+ emitQuote({ symbol: "MSFT", exchange: "NASDAQ" }, quote("MSFT", 200));
77
+ await flushCoordinator();
78
+
79
+ expect(calls).toBe(1);
80
+ });
81
+
82
+ test("coalesces global notifications per microtask", async () => {
83
+ const { provider, emitQuote } = createProvider();
84
+ const coordinator = new MarketDataCoordinator(provider);
85
+ const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
86
+ const msft = { symbol: "MSFT", exchange: "NASDAQ" };
87
+ let calls = 0;
88
+
89
+ coordinator.subscribe(() => { calls += 1; });
90
+ coordinator.subscribeQuotes([{ instrument: aapl }, { instrument: msft }]);
91
+
92
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100));
93
+ emitQuote({ symbol: "MSFT", exchange: "NASDAQ" }, quote("MSFT", 200));
94
+ await flushCoordinator();
95
+
96
+ expect(calls).toBe(1);
97
+ expect(coordinator.getVersion()).toBe(1);
98
+ });
99
+
100
+ test("ignores repeated stream quotes that only refresh timestamp noise", async () => {
101
+ const { provider, emitQuote } = createProvider();
102
+ const coordinator = new MarketDataCoordinator(provider);
103
+ const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
104
+ let calls = 0;
105
+
106
+ coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
107
+ coordinator.subscribeQuotes([{ instrument: aapl }]);
108
+
109
+ const firstTimestamp = 1_700_000_000_000;
110
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
111
+ await flushCoordinator();
112
+
113
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
114
+ await flushCoordinator();
115
+
116
+ expect(calls).toBe(1);
117
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp);
118
+
119
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 20_000 }));
120
+ await flushCoordinator();
121
+
122
+ expect(calls).toBe(2);
123
+ expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
124
+ });
125
+ });
@@ -87,11 +87,80 @@ describe("MarketDataCoordinator", () => {
87
87
  const first = await coordinator.loadChart(request);
88
88
  expect(first.data?.length).toBe(2);
89
89
 
90
- const second = await coordinator.loadChart(request);
90
+ const second = await coordinator.loadChart(request, { forceRefresh: true });
91
91
  expect(second.data).toBeNull();
92
92
  expect(second.lastGoodData?.length).toBe(2);
93
93
  });
94
94
 
95
+ it("reuses fresh snapshots instead of refetching on every pane rerender", async () => {
96
+ let calls = 0;
97
+ const provider = createProvider({
98
+ getTickerFinancials: async () => {
99
+ calls += 1;
100
+ return {
101
+ quote: {
102
+ symbol: "AAPL",
103
+ price: 100 + calls,
104
+ currency: "USD",
105
+ change: 1,
106
+ changePercent: 1,
107
+ lastUpdated: Date.now(),
108
+ },
109
+ fundamentals: { marketCap: calls },
110
+ profile: null,
111
+ annualStatements: [],
112
+ quarterlyStatements: [],
113
+ priceHistory: [],
114
+ };
115
+ },
116
+ });
117
+ const coordinator = new MarketDataCoordinator(provider);
118
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
119
+
120
+ const first = await coordinator.loadSnapshot(instrument);
121
+ const second = await coordinator.loadSnapshot(instrument);
122
+ const refreshed = await coordinator.loadSnapshot(instrument, { forceRefresh: true });
123
+
124
+ expect(first.data?.quote?.price).toBe(101);
125
+ expect(second.data?.quote?.price).toBe(101);
126
+ expect(refreshed.data?.quote?.price).toBe(102);
127
+ expect(calls).toBe(2);
128
+ });
129
+
130
+ it("reuses fresh empty tab query results instead of refetching on reopen", async () => {
131
+ let newsCalls = 0;
132
+ let optionsCalls = 0;
133
+ const provider = createProvider({
134
+ getNews: async () => {
135
+ newsCalls += 1;
136
+ return [];
137
+ },
138
+ getOptionsChain: async () => {
139
+ optionsCalls += 1;
140
+ return {
141
+ underlyingSymbol: "AAPL",
142
+ expirationDates: [],
143
+ calls: [],
144
+ puts: [],
145
+ };
146
+ },
147
+ });
148
+ const coordinator = new MarketDataCoordinator(provider);
149
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
150
+
151
+ const firstNews = await coordinator.loadNews({ instrument, count: 20 });
152
+ const secondNews = await coordinator.loadNews({ instrument, count: 20 });
153
+ const firstOptions = await coordinator.loadOptions({ instrument });
154
+ const secondOptions = await coordinator.loadOptions({ instrument });
155
+
156
+ expect(firstNews.error?.reasonCode).toBe("NO_DATA");
157
+ expect(secondNews.error?.reasonCode).toBe("NO_DATA");
158
+ expect(firstOptions.error?.reasonCode).toBe("NO_DATA");
159
+ expect(secondOptions.error?.reasonCode).toBe("NO_DATA");
160
+ expect(newsCalls).toBe(1);
161
+ expect(optionsCalls).toBe(1);
162
+ });
163
+
95
164
  it("uses cached chart data while a wider range request is loading", async () => {
96
165
  const oneYearHistory = [
97
166
  { date: new Date("2024-01-01"), close: 100 },
@@ -346,7 +415,7 @@ describe("MarketDataCoordinator", () => {
346
415
  change: -0.021,
347
416
  changePercent: -7.89,
348
417
  lastUpdated: Date.now() - 1000,
349
- dataSource: "yahoo",
418
+ dataSource: "delayed",
350
419
  },
351
420
  annualStatements: [],
352
421
  quarterlyStatements: [],
@@ -385,7 +454,7 @@ describe("MarketDataCoordinator", () => {
385
454
  change: -0.021,
386
455
  changePercent: -7.89,
387
456
  lastUpdated: Date.now() - 1000,
388
- dataSource: "yahoo",
457
+ dataSource: "delayed",
389
458
  },
390
459
  annualStatements: [],
391
460
  quarterlyStatements: [],
@@ -432,7 +501,7 @@ describe("MarketDataCoordinator", () => {
432
501
  quote: {
433
502
  symbol: "HY9H",
434
503
  providerId: "yahoo",
435
- dataSource: "yahoo",
504
+ dataSource: "delayed",
436
505
  price: 598,
437
506
  currency: "EUR",
438
507
  change: 6,
@@ -1,4 +1,5 @@
1
- import type { DataProvider, NewsItem, SecFilingItem } from "../types/data-provider";
1
+ import type { DataProvider, SecFilingItem } from "../types/data-provider";
2
+ import type { NewsArticle } from "../news/types";
2
3
  import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
3
4
  import type { ChartRequest, InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest } from "./request-types";
4
5
  import { QueryStore } from "./query-store";
@@ -26,10 +27,19 @@ import { traceMarketData } from "./trace";
26
27
  import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
27
28
  import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
28
29
  import { TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
30
+ import { measurePerf } from "../utils/perf-marks";
29
31
 
30
32
  const EMPTY_MESSAGE = "No data available";
31
33
  const EXPECTED_EMPTY = /no data|not found|delisted|unavailable|unsupported/i;
32
34
  const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
35
+ const SNAPSHOT_CACHE_TTL_MS = 5 * 60_000;
36
+ const CHART_CACHE_TTL_MS = 10 * 60_000;
37
+ const NEWS_CACHE_TTL_MS = 2 * 60_000;
38
+ const OPTIONS_CACHE_TTL_MS = 10 * 60_000;
39
+ const SEC_FILINGS_CACHE_TTL_MS = 10 * 60_000;
40
+ const SEC_CONTENT_CACHE_TTL_MS = 24 * 60 * 60_000;
41
+ const ARTICLE_SUMMARY_CACHE_TTL_MS = 24 * 60 * 60_000;
42
+ const FX_CACHE_TTL_MS = 30 * 60_000;
33
43
 
34
44
  function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
35
45
  return {
@@ -69,6 +79,15 @@ function classifyError(error: unknown): { reasonCode: ProviderReasonCode; messag
69
79
  return { reasonCode: "UPSTREAM_ERROR", message };
70
80
  }
71
81
 
82
+ function hasFreshEntryData<T>(entry: QueryEntry<T>, ttlMs: number, now = Date.now()): boolean {
83
+ if (resolveEntryData(entry) == null) return false;
84
+ return entry.fetchedAt != null && now - entry.fetchedAt < ttlMs;
85
+ }
86
+
87
+ function hasFreshReadyEntry<T>(entry: QueryEntry<T>, ttlMs: number, now = Date.now()): boolean {
88
+ return entry.phase === "ready" && entry.fetchedAt != null && now - entry.fetchedAt < ttlMs;
89
+ }
90
+
72
91
  function createAttempt(
73
92
  providerId: string,
74
93
  startedAt: number,
@@ -132,6 +151,57 @@ function readyQuoteEntry(
132
151
  return readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true });
133
152
  }
134
153
 
154
+ const STREAM_QUOTE_FIELDS: Array<keyof Quote> = [
155
+ "symbol",
156
+ "providerId",
157
+ "price",
158
+ "currency",
159
+ "change",
160
+ "changePercent",
161
+ "previousClose",
162
+ "high52w",
163
+ "low52w",
164
+ "marketCap",
165
+ "volume",
166
+ "name",
167
+ "exchangeName",
168
+ "fullExchangeName",
169
+ "listingExchangeName",
170
+ "listingExchangeFullName",
171
+ "routingExchangeName",
172
+ "routingExchangeFullName",
173
+ "marketState",
174
+ "sessionConfidence",
175
+ "preMarketPrice",
176
+ "preMarketChange",
177
+ "preMarketChangePercent",
178
+ "postMarketPrice",
179
+ "postMarketChange",
180
+ "postMarketChangePercent",
181
+ "bid",
182
+ "ask",
183
+ "bidSize",
184
+ "askSize",
185
+ "open",
186
+ "high",
187
+ "low",
188
+ "mark",
189
+ "dataSource",
190
+ ];
191
+
192
+ function quoteTimestampMinute(quote: Quote): number | null {
193
+ return Number.isFinite(quote.lastUpdated) ? Math.floor(quote.lastUpdated / 60_000) : null;
194
+ }
195
+
196
+ function areStreamQuotesEquivalent(current: Quote | null | undefined, next: Quote): boolean {
197
+ if (!current) return false;
198
+ for (const field of STREAM_QUOTE_FIELDS) {
199
+ if (current[field] !== next[field]) return false;
200
+ }
201
+ return quoteTimestampMinute(current) === quoteTimestampMinute(next)
202
+ && JSON.stringify(current.provenance ?? null) === JSON.stringify(next.provenance ?? null);
203
+ }
204
+
135
205
  function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
136
206
  return {
137
207
  ...current,
@@ -147,28 +217,55 @@ function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryE
147
217
 
148
218
  export class MarketDataCoordinator {
149
219
  private version = 0;
220
+ private pendingVersionBump = false;
221
+ private pendingChangedKeys = new Set<string>();
150
222
  private readonly listeners = new Set<() => void>();
223
+ private readonly keyListeners = new Map<string, Set<() => void>>();
224
+ private readonly keyVersions = new Map<string, number>();
151
225
  private readonly inFlight = new Map<string, Promise<unknown>>();
152
226
  private readonly chartRequests = new Map<string, ChartRequest>();
153
227
 
154
- private readonly quoteStore = new QueryStore<Quote>(() => this.bump());
155
- private readonly snapshotStore = new QueryStore<TickerFinancials>(() => this.bump());
156
- private readonly profileStore = new QueryStore<TickerFinancials["profile"]>(() => this.bump());
157
- private readonly fundamentalsStore = new QueryStore<TickerFinancials["fundamentals"]>(() => this.bump());
158
- private readonly statementsStore = new QueryStore<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>(() => this.bump());
159
- private readonly chartStore = new QueryStore<PricePoint[]>(() => this.bump());
160
- private readonly newsStore = new QueryStore<NewsItem[]>(() => this.bump());
161
- private readonly optionsStore = new QueryStore<OptionsChain>(() => this.bump());
162
- private readonly secFilingsStore = new QueryStore<SecFilingItem[]>(() => this.bump());
163
- private readonly secContentStore = new QueryStore<string | null>(() => this.bump());
164
- private readonly articleSummaryStore = new QueryStore<string | null>(() => this.bump());
165
- private readonly fxStore = new QueryStore<number>(() => this.bump());
228
+ private readonly quoteStore = new QueryStore<Quote>((key) => this.bump(key));
229
+ private readonly snapshotStore = new QueryStore<TickerFinancials>((key) => this.bump(key));
230
+ private readonly profileStore = new QueryStore<TickerFinancials["profile"]>((key) => this.bump(key));
231
+ private readonly fundamentalsStore = new QueryStore<TickerFinancials["fundamentals"]>((key) => this.bump(key));
232
+ private readonly statementsStore = new QueryStore<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>((key) => this.bump(key));
233
+ private readonly chartStore = new QueryStore<PricePoint[]>((key) => this.bump(key));
234
+ private readonly newsStore = new QueryStore<NewsArticle[]>((key) => this.bump(key));
235
+ private readonly optionsStore = new QueryStore<OptionsChain>((key) => this.bump(key));
236
+ private readonly secFilingsStore = new QueryStore<SecFilingItem[]>((key) => this.bump(key));
237
+ private readonly secContentStore = new QueryStore<string | null>((key) => this.bump(key));
238
+ private readonly articleSummaryStore = new QueryStore<string | null>((key) => this.bump(key));
239
+ private readonly fxStore = new QueryStore<number>((key) => this.bump(key));
166
240
 
167
241
  constructor(private readonly dataProvider: DataProvider) {}
168
242
 
169
- private bump(): void {
170
- this.version += 1;
171
- for (const listener of this.listeners) listener();
243
+ private bump(changeKey?: string): void {
244
+ if (changeKey) this.pendingChangedKeys.add(changeKey);
245
+ if (this.pendingVersionBump) return;
246
+ this.pendingVersionBump = true;
247
+ queueMicrotask(() => this.flushBump());
248
+ }
249
+
250
+ private flushBump(): void {
251
+ this.pendingVersionBump = false;
252
+ const changedKeys = this.pendingChangedKeys;
253
+ this.pendingChangedKeys = new Set();
254
+ measurePerf("market-data.bump", () => {
255
+ this.version += 1;
256
+ for (const key of changedKeys) {
257
+ this.keyVersions.set(key, (this.keyVersions.get(key) ?? 0) + 1);
258
+ }
259
+ const keyListeners = new Set<() => void>();
260
+ for (const key of changedKeys) {
261
+ for (const listener of this.keyListeners.get(key) ?? []) {
262
+ keyListeners.add(listener);
263
+ }
264
+ }
265
+
266
+ for (const listener of this.listeners) listener();
267
+ for (const listener of keyListeners) listener();
268
+ }, { changedKeyCount: changedKeys.size });
172
269
  }
173
270
 
174
271
  subscribe(listener: () => void): () => void {
@@ -178,10 +275,35 @@ export class MarketDataCoordinator {
178
275
  };
179
276
  }
180
277
 
278
+ subscribeKeys(keys: readonly string[], listener: () => void): () => void {
279
+ const uniqueKeys = [...new Set(keys)];
280
+ for (const key of uniqueKeys) {
281
+ if (!this.keyListeners.has(key)) this.keyListeners.set(key, new Set());
282
+ this.keyListeners.get(key)!.add(listener);
283
+ }
284
+ return () => {
285
+ for (const key of uniqueKeys) {
286
+ const listeners = this.keyListeners.get(key);
287
+ listeners?.delete(listener);
288
+ if (listeners?.size === 0) {
289
+ this.keyListeners.delete(key);
290
+ }
291
+ }
292
+ };
293
+ }
294
+
181
295
  getVersion(): number {
182
296
  return this.version;
183
297
  }
184
298
 
299
+ getKeysVersion(keys: readonly string[]): number {
300
+ let version = 0;
301
+ for (const key of new Set(keys)) {
302
+ version += this.keyVersions.get(key) ?? 0;
303
+ }
304
+ return version;
305
+ }
306
+
185
307
  getQuoteEntry(instrument: InstrumentRef): QueryEntry<Quote> {
186
308
  return this.quoteStore.get(buildQuoteKey(instrument));
187
309
  }
@@ -194,7 +316,7 @@ export class MarketDataCoordinator {
194
316
  return this.chartStore.get(buildChartKey(request));
195
317
  }
196
318
 
197
- getNewsEntry(request: NewsRequest): QueryEntry<NewsItem[]> {
319
+ getNewsEntry(request: NewsRequest): QueryEntry<NewsArticle[]> {
198
320
  return this.newsStore.get(buildNewsKey(request));
199
321
  }
200
322
 
@@ -354,6 +476,10 @@ export class MarketDataCoordinator {
354
476
  options: { forceRefresh?: boolean } = {},
355
477
  ): Promise<QueryEntry<TickerFinancials>> {
356
478
  const key = buildSnapshotKey(instrument);
479
+ const current = this.snapshotStore.get(key);
480
+ if (!options.forceRefresh && hasFreshEntryData(current, SNAPSHOT_CACHE_TTL_MS)) {
481
+ return current;
482
+ }
357
483
  const flightKey = options.forceRefresh ? `${key}|refresh` : key;
358
484
  return this.runSingleFlight(flightKey, async () => {
359
485
  this.snapshotStore.update(key, loadingEntry);
@@ -448,6 +574,10 @@ export class MarketDataCoordinator {
448
574
  ): Promise<QueryEntry<PricePoint[]>> {
449
575
  const key = buildChartKey(request);
450
576
  this.chartRequests.set(key, request);
577
+ const current = this.chartStore.get(key);
578
+ if (!options.forceRefresh && hasFreshEntryData(current, CHART_CACHE_TTL_MS)) {
579
+ return current;
580
+ }
451
581
  const flightKey = options.forceRefresh ? `${key}|refresh` : key;
452
582
  return this.runSingleFlight(flightKey, async () => {
453
583
  this.chartStore.update(key, (current) => this.createChartLoadingEntry(key, request, current));
@@ -498,18 +628,33 @@ export class MarketDataCoordinator {
498
628
  });
499
629
  }
500
630
 
501
- async loadNews(request: NewsRequest): Promise<QueryEntry<NewsItem[]>> {
631
+ async loadNews(request: NewsRequest): Promise<QueryEntry<NewsArticle[]>> {
502
632
  const key = buildNewsKey(request);
633
+ const current = this.newsStore.get(key);
634
+ if (hasFreshReadyEntry(current, NEWS_CACHE_TTL_MS)) {
635
+ return current;
636
+ }
503
637
  return this.runSingleFlight(key, async () => {
504
638
  this.newsStore.update(key, loadingEntry);
505
639
  const startedAt = Date.now();
506
640
  try {
507
- const data = await this.dataProvider.getNews(
508
- request.instrument.symbol,
509
- request.count ?? 50,
510
- request.instrument.exchange ?? "",
511
- toMarketDataContext(request.instrument),
512
- );
641
+ const newsProvider = this.dataProvider as DataProvider & {
642
+ getNews?: (query: {
643
+ feed: "ticker";
644
+ ticker: string;
645
+ exchange?: string;
646
+ tickerTier: "primary";
647
+ limit?: number;
648
+ }) => Promise<NewsArticle[]>;
649
+ };
650
+ if (!newsProvider.getNews) throw new Error("No news provider available");
651
+ const data = await newsProvider.getNews({
652
+ feed: "ticker",
653
+ ticker: request.instrument.symbol,
654
+ exchange: request.instrument.exchange ?? "",
655
+ tickerTier: "primary",
656
+ limit: request.count ?? 50,
657
+ });
513
658
  const attempts = [createAttempt(this.dataProvider.id, startedAt, data.length > 0 ? "success" : "empty", data.length === 0 ? "NO_DATA" : undefined)];
514
659
  return this.newsStore.update(key, (current) => readyEntry(current, data.length > 0 ? data : null, this.dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
515
660
  } catch (error) {
@@ -522,6 +667,10 @@ export class MarketDataCoordinator {
522
667
 
523
668
  async loadOptions(request: OptionsRequest): Promise<QueryEntry<OptionsChain>> {
524
669
  const key = buildOptionsKey(request);
670
+ const current = this.optionsStore.get(key);
671
+ if (hasFreshReadyEntry(current, OPTIONS_CACHE_TTL_MS)) {
672
+ return current;
673
+ }
525
674
  return this.runSingleFlight(key, async () => {
526
675
  this.optionsStore.update(key, loadingEntry);
527
676
  const startedAt = Date.now();
@@ -548,6 +697,10 @@ export class MarketDataCoordinator {
548
697
 
549
698
  async loadSecFilings(request: SecFilingsRequest): Promise<QueryEntry<SecFilingItem[]>> {
550
699
  const key = buildSecFilingsKey(request);
700
+ const current = this.secFilingsStore.get(key);
701
+ if (hasFreshReadyEntry(current, SEC_FILINGS_CACHE_TTL_MS)) {
702
+ return current;
703
+ }
551
704
  return this.runSingleFlight(key, async () => {
552
705
  this.secFilingsStore.update(key, loadingEntry);
553
706
  const startedAt = Date.now();
@@ -574,6 +727,10 @@ export class MarketDataCoordinator {
574
727
 
575
728
  async loadSecFilingContent(filing: SecFilingItem): Promise<QueryEntry<string | null>> {
576
729
  const key = buildSecContentKey(filing.accessionNumber);
730
+ const current = this.secContentStore.get(key);
731
+ if (hasFreshReadyEntry(current, SEC_CONTENT_CACHE_TTL_MS)) {
732
+ return current;
733
+ }
577
734
  return this.runSingleFlight(key, async () => {
578
735
  this.secContentStore.update(key, loadingEntry);
579
736
  const startedAt = Date.now();
@@ -596,6 +753,10 @@ export class MarketDataCoordinator {
596
753
 
597
754
  async loadArticleSummary(url: string): Promise<QueryEntry<string | null>> {
598
755
  const key = buildArticleSummaryKey(url);
756
+ const current = this.articleSummaryStore.get(key);
757
+ if (hasFreshReadyEntry(current, ARTICLE_SUMMARY_CACHE_TTL_MS)) {
758
+ return current;
759
+ }
599
760
  return this.runSingleFlight(key, async () => {
600
761
  this.articleSummaryStore.update(key, loadingEntry);
601
762
  const startedAt = Date.now();
@@ -614,6 +775,10 @@ export class MarketDataCoordinator {
614
775
 
615
776
  async loadFxRate(currency: string): Promise<QueryEntry<number>> {
616
777
  const key = buildFxKey(currency);
778
+ const current = this.fxStore.get(key);
779
+ if (hasFreshEntryData(current, FX_CACHE_TTL_MS)) {
780
+ return current;
781
+ }
617
782
  return this.runSingleFlight(key, async () => {
618
783
  this.fxStore.update(key, loadingEntry);
619
784
  const startedAt = Date.now();
@@ -650,6 +815,7 @@ export class MarketDataCoordinator {
650
815
  };
651
816
  const key = buildQuoteKey(instrument);
652
817
  const current = this.quoteStore.get(key);
818
+ if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
653
819
  const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
654
820
  this.quoteStore.set(key, readyQuoteEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts));
655
821
  });
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useState } from "react";
3
- import { testRender } from "@opentui/react/test-utils";
3
+ import { testRender } from "../renderers/opentui/test-utils";
4
4
  import type { PricePoint, TickerFinancials } from "../types/financials";
5
5
  import type { TickerRecord } from "../types/ticker";
6
6
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./coordinator";