gloomberb 0.16.6 → 0.17.0

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 (348) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat-pane-link.ts +47 -0
  3. package/src/api-client/chat.ts +9 -1
  4. package/src/api-client/data.ts +37 -1
  5. package/src/api-client/equity-screener.ts +3 -0
  6. package/src/api-client/europe-energy.ts +91 -0
  7. package/src/api-client/index.ts +6 -0
  8. package/src/api-client/letters.ts +41 -0
  9. package/src/api-client/normalizers.ts +36 -6
  10. package/src/api-client/organization-tickers.ts +44 -0
  11. package/src/api-client/request.ts +3 -1
  12. package/src/api-client/types.ts +27 -1
  13. package/src/app/runtime/desktop-deeplink.ts +48 -15
  14. package/src/architecture/core-builtin-allowlist.json +1 -1
  15. package/src/architecture/dom-cell-metrics-allowlist.json +28 -0
  16. package/src/ask-gloom/pane-context.ts +23 -19
  17. package/src/ask-gloom/pane-request.ts +2 -2
  18. package/src/cli/commands/fred-series.ts +77 -11
  19. package/src/cli/commands/market.ts +77 -20
  20. package/src/cli/commands/overview.ts +29 -20
  21. package/src/cli/commands/ticker.ts +64 -11
  22. package/src/cli/desktop-pane-shot-view.ts +114 -0
  23. package/src/cli/desktop-pane-shot.ts +30 -31
  24. package/src/cli/entry.ts +7 -0
  25. package/src/cli/export-source.ts +43 -0
  26. package/src/cli/freshness.ts +18 -3
  27. package/src/cli/glossary.ts +17 -0
  28. package/src/cli/history-rows.ts +174 -13
  29. package/src/cli/known-gaps.ts +14 -3
  30. package/src/cli/listing-arg.ts +20 -2
  31. package/src/cli/listing-suggestion.ts +159 -0
  32. package/src/cli/native-smoke.ts +13 -0
  33. package/src/cli/not-a-ticker.ts +13 -1
  34. package/src/cli/pane-functions/capabilities.ts +16 -8
  35. package/src/cli/pane-functions/dom.ts +28 -13
  36. package/src/cli/pane-functions/function-help.ts +51 -6
  37. package/src/cli/pane-functions/headless.ts +26 -7
  38. package/src/cli/pane-functions/index.ts +46 -3
  39. package/src/cli/pane-functions/report-notices.ts +6 -3
  40. package/src/cli/pane-functions/report.ts +16 -2
  41. package/src/cli/pane-functions/resolver.ts +7 -4
  42. package/src/cli/report-tables.ts +31 -7
  43. package/src/cli/result.ts +54 -11
  44. package/src/cli/symbol-rows.ts +18 -1
  45. package/src/components/chart/composite/axis-overlays.tsx +2 -0
  46. package/src/components/chart/composite/composite-chart.tsx +4 -21
  47. package/src/components/chart/composite/legend.tsx +10 -6
  48. package/src/components/chart/composite/price-axis-labels.tsx +1 -0
  49. package/src/components/command-bar/list/model.ts +2 -0
  50. package/src/components/command-bar/pane-templates/items.ts +1 -1
  51. package/src/components/command-bar/routes/ticker-search/actions.ts +1 -0
  52. package/src/components/command-bar/routes/ticker-search/results.ts +3 -1
  53. package/src/components/command-bar/workflow/ops.ts +8 -97
  54. package/src/components/command-bar/workflow/pane-reuse.ts +177 -0
  55. package/src/components/data-table/view.tsx +1 -0
  56. package/src/components/feed-data-table/stack-view.tsx +18 -42
  57. package/src/components/layout/missing-pane.tsx +2 -0
  58. package/src/components/layout/shell/index.tsx +1 -0
  59. package/src/components/layout/shell/pane/ask.ts +12 -7
  60. package/src/components/layout/shell/pane/capture.ts +8 -34
  61. package/src/components/markdown-text.tsx +37 -6
  62. package/src/components/reader/article-reader.tsx +91 -266
  63. package/src/components/reader/detail-reader.tsx +210 -0
  64. package/src/components/reader/reader-text.tsx +239 -0
  65. package/src/components/table-view-shared.tsx +8 -4
  66. package/src/components/ticker/badge/list.tsx +15 -4
  67. package/src/components/ui/choice-dialog.tsx +3 -0
  68. package/src/components/ui/data-table/types.ts +7 -0
  69. package/src/components/ui/detail-scroll-body.tsx +31 -4
  70. package/src/components/ui/display.tsx +46 -1
  71. package/src/components/ui/icon.tsx +11 -1
  72. package/src/components/ui/list-view.tsx +3 -1
  73. package/src/components/ui/query-bar-keys.tsx +2 -0
  74. package/src/components/ui/query-bar.tsx +9 -1
  75. package/src/components/ui/select-button.tsx +3 -0
  76. package/src/data/config/store/fold-panes.ts +202 -11
  77. package/src/data/config/store/migrations.ts +13 -1
  78. package/src/docs/snapshot.json +29 -0
  79. package/src/i18n/es.ts +14 -0
  80. package/src/i18n/ja.ts +14 -0
  81. package/src/i18n/ko.ts +14 -0
  82. package/src/i18n/zh-cn.ts +14 -0
  83. package/src/i18n/zh-tw.ts +14 -0
  84. package/src/layout/desk-keys.ts +7 -0
  85. package/src/layout/desks.ts +2 -2
  86. package/src/market-data/market/status.ts +67 -6
  87. package/src/market-data/quotes/contributions.ts +15 -0
  88. package/src/market-data/quotes/regular-session-valuation.ts +131 -0
  89. package/src/market-data/quotes/resolution.ts +4 -0
  90. package/src/market-data/snapshot-provider.ts +6 -3
  91. package/src/notifications/app-badge.ts +30 -0
  92. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  93. package/src/plugins/builtin/account-management/form-components.tsx +53 -1
  94. package/src/plugins/builtin/account-management/keyboard.ts +5 -0
  95. package/src/plugins/builtin/account-management/model.ts +27 -0
  96. package/src/plugins/builtin/account-management/pane.tsx +62 -2
  97. package/src/plugins/builtin/alerts/cli.ts +6 -4
  98. package/src/plugins/builtin/analytics/index.tsx +1 -1
  99. package/src/plugins/builtin/awards/pane.tsx +29 -18
  100. package/src/plugins/builtin/backtest/pane.tsx +2 -1
  101. package/src/plugins/builtin/bond-calculator/index.tsx +3 -5
  102. package/src/plugins/builtin/buildout/detail/company.tsx +7 -4
  103. package/src/plugins/builtin/builtin-plugin-meta.ts +5 -4
  104. package/src/plugins/builtin/catalysts/model.ts +92 -10
  105. package/src/plugins/builtin/catalysts/pane.tsx +6 -4
  106. package/src/plugins/builtin/cds/index.tsx +21 -30
  107. package/src/plugins/builtin/changelog/index.tsx +11 -12
  108. package/src/plugins/builtin/chart-composer/headless-schema.ts +2 -0
  109. package/src/plugins/builtin/chart-composer/headless.ts +87 -5
  110. package/src/plugins/builtin/chart-composer/pane.tsx +3 -2
  111. package/src/plugins/builtin/chart-composer/session-history.ts +20 -5
  112. package/src/plugins/builtin/chat/command-bar.ts +6 -8
  113. package/src/plugins/builtin/chat/content/composer-runtime.ts +2 -2
  114. package/src/plugins/builtin/chat/content/composer.tsx +13 -4
  115. package/src/plugins/builtin/chat/content/footer.tsx +15 -5
  116. package/src/plugins/builtin/chat/content/handle-prompt.tsx +326 -0
  117. package/src/plugins/builtin/chat/content/index.tsx +113 -9
  118. package/src/plugins/builtin/chat/content/layout-metrics.ts +4 -0
  119. package/src/plugins/builtin/chat/content/open-targets.ts +20 -4
  120. package/src/plugins/builtin/chat/content/scroll.ts +6 -1
  121. package/src/plugins/builtin/chat/content/transcript.tsx +11 -2
  122. package/src/plugins/builtin/chat/ghost-user.ts +1 -1
  123. package/src/plugins/builtin/chat/handle-prompt.ts +128 -0
  124. package/src/plugins/builtin/chat/index.tsx +44 -21
  125. package/src/plugins/builtin/chat/layout.ts +7 -14
  126. package/src/plugins/builtin/chat/message/desktop.tsx +59 -11
  127. package/src/plugins/builtin/chat/message/header.tsx +69 -21
  128. package/src/plugins/builtin/chat/message/pane-chip.tsx +223 -0
  129. package/src/plugins/builtin/chat/message/person-icons.tsx +53 -0
  130. package/src/plugins/builtin/chat/message/profile-popover.tsx +73 -9
  131. package/src/plugins/builtin/chat/message/render-state.ts +15 -2
  132. package/src/plugins/builtin/chat/message/terminal.tsx +33 -8
  133. package/src/plugins/builtin/chat/message/types.ts +3 -0
  134. package/src/plugins/builtin/chat/pane-links/model.ts +149 -0
  135. package/src/plugins/builtin/chat/pane-links/open.ts +29 -0
  136. package/src/plugins/builtin/chat/pane-links/previews.ts +119 -0
  137. package/src/plugins/builtin/chat/pane-state.ts +5 -8
  138. package/src/plugins/builtin/chat/people.ts +202 -0
  139. package/src/plugins/builtin/chat/profile-popover.ts +18 -2
  140. package/src/plugins/builtin/chat/status-widget.tsx +3 -8
  141. package/src/plugins/builtin/chat/unread-badge.ts +65 -0
  142. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +9 -6
  143. package/src/plugins/builtin/clinical-trials/client.ts +26 -1
  144. package/src/plugins/builtin/clinical-trials/index.tsx +10 -2
  145. package/src/plugins/builtin/clinical-trials/model.ts +269 -0
  146. package/src/plugins/builtin/clinical-trials/pane.tsx +247 -62
  147. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +156 -0
  148. package/src/plugins/builtin/cloud/askg/attached-pane.tsx +3 -3
  149. package/src/plugins/builtin/cloud/askg/pane-attachment.ts +3 -3
  150. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -0
  151. package/src/plugins/builtin/comment-letters/pane.tsx +30 -11
  152. package/src/plugins/builtin/commodities-energy/index.tsx +179 -0
  153. package/src/plugins/builtin/commodity-balances/client.ts +208 -0
  154. package/src/plugins/builtin/commodity-balances/headless.ts +66 -0
  155. package/src/plugins/builtin/commodity-balances/index.tsx +32 -0
  156. package/src/plugins/builtin/commodity-balances/model.ts +261 -0
  157. package/src/plugins/builtin/commodity-balances/pane.tsx +227 -0
  158. package/src/plugins/builtin/composite-plugins.ts +26 -1
  159. package/src/plugins/builtin/credit-boards/index.tsx +21 -42
  160. package/src/plugins/builtin/credit-boards/model.ts +0 -2
  161. package/src/plugins/builtin/credit-conditions/index.tsx +10 -19
  162. package/src/plugins/builtin/credit-documents/index.tsx +27 -10
  163. package/src/plugins/builtin/debt-maturities/index.tsx +3 -17
  164. package/src/plugins/builtin/debug/index.tsx +2 -2
  165. package/src/plugins/builtin/dividend-yield/client.ts +3 -1
  166. package/src/plugins/builtin/dividend-yield/headless.ts +3 -1
  167. package/src/plugins/builtin/dividend-yield/pane.tsx +3 -1
  168. package/src/plugins/builtin/dividend-yield/provider-client.ts +3 -1
  169. package/src/plugins/builtin/docs/blocks.tsx +4 -3
  170. package/src/plugins/builtin/docs/pane.tsx +16 -8
  171. package/src/plugins/builtin/earnings/index.tsx +2 -0
  172. package/src/plugins/builtin/earnings-calls/pane.tsx +2 -12
  173. package/src/plugins/builtin/equity-screener/client.ts +1 -1
  174. package/src/plugins/builtin/equity-screener/headless.ts +36 -2
  175. package/src/plugins/builtin/equity-screener/model.ts +121 -68
  176. package/src/plugins/builtin/executives/pane.tsx +3 -1
  177. package/src/plugins/builtin/filing-events/index.tsx +20 -0
  178. package/src/plugins/builtin/fixed-income/index.tsx +46 -0
  179. package/src/plugins/builtin/fixed-income/model.ts +27 -0
  180. package/src/plugins/builtin/fixed-income/pane.tsx +162 -0
  181. package/src/plugins/builtin/fixed-income/shortcut.ts +52 -0
  182. package/src/plugins/builtin/futures/index.tsx +24 -7
  183. package/src/plugins/builtin/futures/series.ts +23 -2
  184. package/src/plugins/builtin/futures/use-front-returns.ts +3 -2
  185. package/src/plugins/builtin/futures-curve/pane.tsx +6 -4
  186. package/src/plugins/builtin/help/shortcut-table.tsx +4 -2
  187. package/src/plugins/builtin/insider/detail.tsx +20 -12
  188. package/src/plugins/builtin/insider/index.tsx +2 -2
  189. package/src/plugins/builtin/iv-history/index.tsx +3 -0
  190. package/src/plugins/builtin/iv-history/screen-pane.tsx +13 -5
  191. package/src/plugins/builtin/jodi/client.ts +220 -0
  192. package/src/plugins/builtin/jodi/headless.ts +63 -0
  193. package/src/plugins/builtin/jodi/index.tsx +32 -0
  194. package/src/plugins/builtin/jodi/model.ts +75 -0
  195. package/src/plugins/builtin/jodi/pane.tsx +141 -0
  196. package/src/plugins/builtin/market-heatmap/evidence.ts +13 -4
  197. package/src/plugins/builtin/market-heatmap/headless.ts +3 -3
  198. package/src/plugins/builtin/market-heatmap/index.tsx +41 -23
  199. package/src/plugins/builtin/market-heatmap/live.ts +2 -1
  200. package/src/plugins/builtin/market-heatmap/portfolio.ts +67 -21
  201. package/src/plugins/builtin/market-movers/table.ts +2 -2
  202. package/src/plugins/builtin/market-valuation/align.ts +10 -9
  203. package/src/plugins/builtin/mna/distress/model.ts +52 -1
  204. package/src/plugins/builtin/mna/distress/pane.tsx +17 -12
  205. package/src/plugins/builtin/mna/index.tsx +2 -0
  206. package/src/plugins/builtin/news/wire/index.tsx +2 -2
  207. package/src/plugins/builtin/news/wire/industry-pane.tsx +28 -14
  208. package/src/plugins/builtin/news/wire/letters/client.ts +108 -0
  209. package/src/plugins/builtin/news/wire/letters/model.ts +69 -0
  210. package/src/plugins/builtin/news/wire/letters/pane.tsx +155 -0
  211. package/src/plugins/builtin/news/wire/mutes.ts +2 -1
  212. package/src/plugins/builtin/news/wire/news/query-presets.ts +4 -1
  213. package/src/plugins/builtin/openfda/client.ts +123 -15
  214. package/src/plugins/builtin/openfda/model.ts +163 -0
  215. package/src/plugins/builtin/openfda/pane.tsx +109 -65
  216. package/src/plugins/builtin/openfda/types.ts +25 -0
  217. package/src/plugins/builtin/options/view.tsx +1 -1
  218. package/src/plugins/builtin/pe-band/model.ts +3 -2
  219. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  220. package/src/plugins/builtin/plugin-module.ts +3 -1
  221. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +1 -1
  222. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +19 -4
  223. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -2
  224. package/src/plugins/builtin/portfolio-list/cli/shared.ts +39 -0
  225. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +18 -5
  226. package/src/plugins/builtin/portfolio-list/live-valuation.ts +25 -13
  227. package/src/plugins/builtin/power/evidence.ts +32 -3
  228. package/src/plugins/builtin/power/gas-client.ts +110 -0
  229. package/src/plugins/builtin/power/gas-model.ts +194 -0
  230. package/src/plugins/builtin/power/gas.tsx +145 -0
  231. package/src/plugins/builtin/power/headless.ts +4 -1
  232. package/src/plugins/builtin/power/index.tsx +19 -9
  233. package/src/plugins/builtin/power/pane.tsx +2 -3
  234. package/src/plugins/builtin/power/prices-headless.ts +75 -7
  235. package/src/plugins/builtin/power/prices.tsx +36 -13
  236. package/src/plugins/builtin/primary-dealers/client.ts +108 -0
  237. package/src/plugins/builtin/primary-dealers/headless.ts +53 -0
  238. package/src/plugins/builtin/primary-dealers/index.tsx +17 -0
  239. package/src/plugins/builtin/primary-dealers/model.ts +206 -0
  240. package/src/plugins/builtin/primary-dealers/pane.tsx +149 -0
  241. package/src/plugins/builtin/relative-rotation/index.tsx +3 -0
  242. package/src/plugins/builtin/research/analyst-model.ts +9 -5
  243. package/src/plugins/builtin/research/headless-schema.ts +2 -1
  244. package/src/plugins/builtin/research/relative-valuation-headless.ts +68 -32
  245. package/src/plugins/builtin/research/relative-valuation-model.ts +45 -24
  246. package/src/plugins/builtin/research/relative-valuation-pane.tsx +39 -4
  247. package/src/plugins/builtin/scanner/flow-pane.tsx +8 -1
  248. package/src/plugins/builtin/sec/filing-display.tsx +3 -36
  249. package/src/plugins/builtin/sec/index.tsx +131 -43
  250. package/src/plugins/builtin/sec/model.ts +148 -2
  251. package/src/plugins/builtin/sec/row-summaries.ts +105 -0
  252. package/src/plugins/builtin/sectors/index.tsx +2 -0
  253. package/src/plugins/builtin/sectors/sector-model.ts +2 -2
  254. package/src/plugins/builtin/series-inventory.ts +1 -0
  255. package/src/plugins/builtin/shared/locked-rows.tsx +1 -1
  256. package/src/plugins/builtin/shared/use-quote-board.ts +179 -43
  257. package/src/plugins/builtin/shared/wall-teaser.tsx +2 -2
  258. package/src/plugins/builtin/shared/zip-text.ts +46 -0
  259. package/src/plugins/builtin/short-interest/watch-model.ts +11 -2
  260. package/src/plugins/builtin/themes/model.ts +2 -2
  261. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +11 -0
  262. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +81 -19
  263. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +1 -1
  264. package/src/plugins/builtin/ticker-detail/headless.ts +2 -2
  265. package/src/plugins/builtin/ticker-detail/overview/model.ts +3 -1
  266. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +7 -5
  267. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +8 -6
  268. package/src/plugins/builtin/time-sales/headless.ts +22 -0
  269. package/src/plugins/builtin/trace-bonds/client.ts +196 -0
  270. package/src/plugins/builtin/trace-bonds/headless.ts +69 -0
  271. package/src/plugins/builtin/trace-bonds/index.tsx +33 -0
  272. package/src/plugins/builtin/trace-bonds/model.ts +115 -0
  273. package/src/plugins/builtin/trace-bonds/pane.tsx +191 -0
  274. package/src/plugins/builtin/treasury-auctions/index.tsx +33 -47
  275. package/src/plugins/builtin/treasury-daily/client.ts +96 -0
  276. package/src/plugins/builtin/treasury-daily/headless.ts +69 -0
  277. package/src/plugins/builtin/treasury-daily/index.tsx +17 -0
  278. package/src/plugins/builtin/treasury-daily/model.ts +69 -0
  279. package/src/plugins/builtin/treasury-daily/pane.tsx +140 -0
  280. package/src/plugins/builtin/world-venue-map/entity-detail.tsx +15 -2
  281. package/src/plugins/builtin/world-venue-map/map.tsx +1 -0
  282. package/src/plugins/builtin-editorial.ts +1 -1
  283. package/src/plugins/catalog-browser.ts +25 -2
  284. package/src/plugins/marketplace/model.ts +57 -21
  285. package/src/renderers/dom/build-assets.ts +14 -5
  286. package/src/renderers/dom/data-table/index.tsx +5 -3
  287. package/src/renderers/dom/desktop/controls.tsx +2 -1
  288. package/src/renderers/dom/desktop/icons.tsx +25 -0
  289. package/src/renderers/dom/desktop/list-view.tsx +1 -1
  290. package/src/renderers/dom/desktop/menu.tsx +1 -1
  291. package/src/renderers/dom/desktop/query-bar.tsx +3 -1
  292. package/src/renderers/dom/host/chart-surface.tsx +7 -2
  293. package/src/renderers/dom/host/resize-rows.ts +111 -0
  294. package/src/renderers/dom/host/sticky-scroll.ts +22 -3
  295. package/src/renderers/dom/host/style.ts +14 -0
  296. package/src/renderers/dom/host/surface-3d.tsx +2 -1
  297. package/src/renderers/dom/host/surface-fit.ts +10 -6
  298. package/src/renderers/dom/host/text.tsx +2 -2
  299. package/src/renderers/dom/styles.css +63 -6
  300. package/src/renderers/electrobun/bun/desktop/app-badge.ts +37 -0
  301. package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
  302. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  303. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +37 -0
  304. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -40
  305. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +41 -0
  306. package/src/renderers/electrobun/bun/tui-entry.ts +18 -1
  307. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  308. package/src/renderers/electrobun/view/app-badge-host.ts +17 -0
  309. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +8 -3
  310. package/src/renderers/electrobun/view/main.tsx +2 -0
  311. package/src/renderers/electrobun/view/remote/asset-data-client.ts +5 -1
  312. package/src/series/validate.ts +1 -1
  313. package/src/shares/api.ts +5 -1
  314. package/src/shares/location.ts +31 -0
  315. package/src/shares/open.ts +26 -0
  316. package/src/shares/pane-image.ts +18 -6
  317. package/src/shares/personal-pane.ts +42 -54
  318. package/src/sources/gloomberb-cloud/index.ts +7 -2
  319. package/src/sources/gloomberb-cloud/normalizers.ts +5 -0
  320. package/src/sources/history-coverage.ts +22 -4
  321. package/src/sources/history-result.ts +3 -1
  322. package/src/sources/provider-errors.ts +2 -1
  323. package/src/sources/provider-router/batches.ts +18 -2
  324. package/src/sources/provider-router/index.ts +2 -1
  325. package/src/state/app/context/index.tsx +3 -2
  326. package/src/test-support/builtin-panes.ts +31 -0
  327. package/src/tickers/listing-venues.ts +22 -0
  328. package/src/tickers/search/index.ts +7 -2
  329. package/src/tickers/search/ranking.ts +19 -0
  330. package/src/time-series/chart-data.ts +2 -0
  331. package/src/time-series/parsed-history-cache.ts +41 -11
  332. package/src/time-series/resolve.ts +47 -15
  333. package/src/types/config.ts +1 -1
  334. package/src/types/data-provider.ts +13 -1
  335. package/src/types/financials.ts +13 -0
  336. package/src/types/headless.ts +22 -0
  337. package/src/types/plugin.ts +33 -6
  338. package/src/types/price-history.ts +2 -0
  339. package/src/ui/host.tsx +6 -0
  340. package/src/utils/crypto-pair.ts +1 -1
  341. package/src/utils/currency-pair.ts +66 -0
  342. package/src/utils/decimal-comma.ts +57 -0
  343. package/src/utils/guards.ts +25 -0
  344. package/src/utils/local-utc-offset.ts +37 -0
  345. package/src/utils/plugin-proxy-hosts.json +8 -1
  346. package/src/components/chart/composite/crosshair-readout.tsx +0 -150
  347. package/src/plugins/builtin/chat/message/ai-badge.tsx +0 -18
  348. package/src/plugins/builtin/credit-boards/pane.tsx +0 -30
@@ -46,11 +46,77 @@ export function getFilingDisplayTitle(filing: SecFilingItem): string {
46
46
  return description ? `${formLabel} | ${description}` : formLabel;
47
47
  }
48
48
 
49
- /** The adjacent FORM column already identifies the form. Keep its meaning here. */
49
+ /**
50
+ * 8-K items in a few words, as a reader scans them: what happened, not the
51
+ * SEC's full caption. 9.01 (exhibits) comes with most reports and says little.
52
+ */
53
+ const CURRENT_REPORT_ITEMS: Record<string, string> = {
54
+ "1.01": "Material agreement",
55
+ "1.02": "Agreement terminated",
56
+ "1.03": "Bankruptcy or receivership",
57
+ "1.04": "Mine safety",
58
+ "1.05": "Cybersecurity incident",
59
+ "2.01": "Acquisition or disposal",
60
+ "2.02": "Results of operations",
61
+ "2.03": "New financial obligation",
62
+ "2.04": "Obligation accelerated",
63
+ "2.05": "Exit or restructuring costs",
64
+ "2.06": "Impairment",
65
+ "3.01": "Listing standard notice",
66
+ "3.02": "Unregistered equity sale",
67
+ "3.03": "Shareholder rights modified",
68
+ "4.01": "Auditor change",
69
+ "4.02": "Non-reliance on financials",
70
+ "5.01": "Change in control",
71
+ "5.02": "Officer or director change",
72
+ "5.03": "Charter or bylaws amended",
73
+ "5.04": "Benefit plan trading suspension",
74
+ "5.05": "Code of ethics change",
75
+ "5.06": "Shell company status change",
76
+ "5.07": "Shareholder vote results",
77
+ "5.08": "Director nominations",
78
+ "7.01": "Regulation FD disclosure",
79
+ "8.01": "Other events",
80
+ "9.01": "Exhibits",
81
+ };
82
+
83
+ const isCurrentReport = (form: string) => /^(?:8-K|6-K)(?:\/A)?$/i.test(form.trim());
84
+
85
+ /** `Results of operations; Exhibits` from `2.02,9.01`; exhibits only when nothing else is reported. */
86
+ function currentReportItemsText(items: string | null | undefined): string | null {
87
+ const codes = (secFilingItemCodes(items) ?? "").split(",").filter(Boolean);
88
+ const named = codes.filter((code) => code !== "9.01" || codes.length === 1).map((code) => CURRENT_REPORT_ITEMS[code] ?? `Item ${code}`);
89
+ return named.length > 0 ? named.join("; ") : null;
90
+ }
91
+
92
+ /**
93
+ * The period a 10-K or 10-Q covers, read from its inline XBRL document name
94
+ * (`aapl-20260627.htm`), which by convention carries the period end. A date
95
+ * after the filing, or more than 15 months before it, is not taken.
96
+ */
97
+ function periodicReportPeriod(filing: Pick<SecFilingItem, "form" | "primaryDocument" | "filingDate">): string | null {
98
+ if (!/^(?:10-K|10-Q|10-KT|10-QT|20-F|40-F)(?:\/A)?$/i.test(filing.form.trim())) return null;
99
+ const match = /^[a-z0-9.]+-(\d{4})(\d{2})(\d{2})(?:x\d+)?\.html?$/i.exec(filing.primaryDocument?.split("/").pop() ?? "");
100
+ if (!match) return null;
101
+ const end = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
102
+ const iso = `${match[1]}-${match[2]}-${match[3]}`;
103
+ const filed = new Date(filing.filingDate).getTime();
104
+ if (!Number.isFinite(end.getTime()) || end.toISOString().slice(0, 10) !== iso || !Number.isFinite(filed)) return null;
105
+ const days = (filed - end.getTime()) / 86_400_000;
106
+ return days >= 0 && days <= 460 ? iso : null;
107
+ }
108
+
109
+ /** The adjacent FORM column already identifies the form. Keep its meaning here: what the filing reports. */
50
110
  export function getFilingColumnText(filing: SecFilingItem): string {
51
111
  const description = getMeaningfulPrimaryDescription(filing);
52
112
  const meaning = getFormDescription(filing.form);
53
- if (!description) return meaning || "Filing";
113
+ if (!description) {
114
+ const items = isCurrentReport(filing.form) ? currentReportItemsText(filing.items) : null;
115
+ if (items) return /\/A$/i.test(filing.form.trim()) ? `${items} (amended)` : items;
116
+ const period = periodicReportPeriod(filing);
117
+ if (period) return `${meaning || "Report"}, period ended ${period}`;
118
+ return meaning || "Filing";
119
+ }
54
120
  return meaning && normalizeComparableText(description) !== normalizeComparableText(meaning)
55
121
  ? `${description} | ${meaning}`
56
122
  : description;
@@ -99,6 +165,22 @@ export function getFormDescription(form: string): string {
99
165
  case "NPORT-P": return "Monthly Portfolio Holdings Report";
100
166
  case "NPORT-EX": return "Portfolio Holdings Exhibit";
101
167
  case "24F-2NT": return "Annual Notice of Securities Sold";
168
+ case "SD": return "Conflict Minerals Report";
169
+ case "S-3": return "Shelf Registration";
170
+ case "S-8": return "Employee Plan Registration";
171
+ case "FWP": return "Free Writing Prospectus";
172
+ case "DEFA14A": return "Additional Proxy Materials";
173
+ case "PX14A6G": return "Shareholder Exempt Solicitation";
174
+ case "11-K": return "Employee Plan Annual Report";
175
+ case "ARS": return "Annual Report to Shareholders";
176
+ case "NT 10-K": return "Late Annual Report Notice";
177
+ case "NT 10-Q": return "Late Quarterly Report Notice";
178
+ case "25-NSE": return "Delisting Notice";
179
+ case "6-K": return "Foreign Issuer Current Report";
180
+ case "40-F": return "Annual Report (Canadian)";
181
+ case "424B2":
182
+ case "424B3":
183
+ case "424B5": return "Prospectus";
102
184
  default: {
103
185
  const base = normalized.endsWith("/A") ? getFormDescription(normalized.slice(0, -2)) : "";
104
186
  return base ? `${base} (Amended)` : "";
@@ -106,6 +188,70 @@ export function getFormDescription(form: string): string {
106
188
  }
107
189
  }
108
190
 
191
+ /** The groups of the Form filter: a reader looks for reports, events or insiders, not one form code. */
192
+ export interface FormGroup {
193
+ value: string;
194
+ label: string;
195
+ matches: (form: string) => boolean;
196
+ }
197
+
198
+ const upper = (form: string) => form.trim().toUpperCase().replace(/\/A$/, "");
199
+ const oneOf = (...forms: string[]) => (form: string) => forms.includes(upper(form));
200
+
201
+ export const ALL_FORMS = "all";
202
+
203
+ export const COMPANY_FORM_GROUPS: readonly FormGroup[] = [
204
+ { value: "periodic", label: "Annual & quarterly", matches: oneOf("10-K", "10-Q", "10-KT", "10-QT", "20-F", "40-F", "NT 10-K", "NT 10-Q", "NT 20-F") },
205
+ { value: "current", label: "Current reports", matches: (form) => isCurrentReport(form) || upper(form) === "8-K12B" },
206
+ { value: "insider", label: "Insider", matches: oneOf("3", "4", "5", "144") },
207
+ { value: "ownership", label: "13D / 13G", matches: (form) => /^(?:SC|SCHEDULE) 13[DG]$/.test(upper(form)) },
208
+ { value: "proxy", label: "Proxy", matches: (form) => /^(?:DEF|DEFA|DEFM|PRE|PREM|DFAN)14[AC]$/.test(upper(form).replace(/\s+/g, "")) },
209
+ { value: "offerings", label: "Registrations & offerings", matches: (form) => /^(?:S-\d|F-\d|424B\d|FWP|S-8 POS|POS AM|425)/.test(upper(form)) },
210
+ ];
211
+
212
+ export const FUND_FORM_GROUPS: readonly FormGroup[] = [
213
+ { value: "reports", label: "Shareholder reports", matches: oneOf("N-CSR", "N-CSRS", "N-30D") },
214
+ { value: "holdings", label: "Holdings", matches: oneOf("NPORT-P", "NPORT-EX") },
215
+ { value: "prospectus", label: "Prospectus & registration", matches: oneOf("N-1A", "N-2", "485APOS", "485BPOS", "485BXT", "497", "497J", "497K") },
216
+ { value: "census", label: "Flows & census", matches: oneOf("N-CEN", "24F-2NT") },
217
+ ];
218
+
219
+ /** Filings in a group; `all` and an unknown group keep every filing. */
220
+ export function filterFilingsByGroup<T extends { form: string }>(filings: readonly T[], groups: readonly FormGroup[], value: string): T[] {
221
+ const group = groups.find((entry) => entry.value === value);
222
+ return group ? filings.filter((filing) => group.matches(filing.form)) : [...filings];
223
+ }
224
+
225
+ export type FilingTone = "positive" | "negative" | "warning" | "accent" | "muted" | "text";
226
+
227
+ /**
228
+ * How much a filing matters, in the tone every research pane gives a state:
229
+ * events that can sink a company (bankruptcy, delisting, non-reliance, a late
230
+ * report) red, an insider sale red and a purchase green, a planned sale, an
231
+ * activist stake, a management or auditor change amber, the periodic reports,
232
+ * earnings and fund shareholder reports in the accent, and routine paperwork
233
+ * quiet. A fund's annual notice of securities sold reads green for a year of
234
+ * net creations and red for net redemptions. The form text never changes, so
235
+ * the colour is not the only signal.
236
+ */
237
+ export function filingTone(filing: Pick<SecFilingItem, "form" | "items">, insiderDirection?: "buy" | "sell" | null): FilingTone {
238
+ const form = upper(filing.form);
239
+ if (/^NT /.test(form)) return "negative";
240
+ if (isCurrentReport(filing.form)) {
241
+ const codes = (secFilingItemCodes(filing.items) ?? "").split(",");
242
+ if (codes.some((code) => code === "1.03" || code === "3.01" || code === "4.02")) return "negative";
243
+ if (codes.some((code) => ["2.05", "2.06", "4.01", "5.01", "5.02", "1.05"].includes(code))) return "warning";
244
+ if (codes.includes("2.02")) return "accent";
245
+ return "text";
246
+ }
247
+ if (form === "4" || form === "5" || form === "24F-2NT") return insiderDirection === "sell" ? "negative" : insiderDirection === "buy" ? "positive" : form === "24F-2NT" ? "muted" : "text";
248
+ if (form === "144") return "warning";
249
+ if (/13D$/.test(form)) return "warning";
250
+ if (/^(?:10-K|10-Q|10-KT|10-QT|20-F|40-F|N-CSR|N-CSRS|N-30D|N-1A|N-2)$/.test(form)) return "accent";
251
+ if (/^(?:3|SC 13G|SCHEDULE 13G|SD|497|497J|497K|485BPOS|485BXT|NPORT-P|NPORT-EX)$/.test(form)) return "muted";
252
+ return "text";
253
+ }
254
+
109
255
  export function buildSecFilingRows(filings: readonly SecFilingItem[]) {
110
256
  return filings.map((filing) => {
111
257
  return {
@@ -0,0 +1,105 @@
1
+ import type { SecFilingItem } from "../../../types/data-provider";
2
+ import { decodeHtmlEntities } from "../../../utils/html-entities";
3
+ import { formatCompact } from "../../../utils/format";
4
+ import { parseForm4Xml, transactionTypeLabel } from "../insider/insider-data";
5
+ import { buildInsiderTransactionTitle } from "./filing-display";
6
+
7
+ /**
8
+ * What a small XML filing's own text says, for its row: who traded and which
9
+ * way on an insider form, how much money came in or went out on a fund's
10
+ * annual notice of securities sold.
11
+ */
12
+ export interface RowSummary {
13
+ title: string;
14
+ /** An open-market purchase or sale, or a fund's net creations or redemptions. */
15
+ direction: "buy" | "sell" | null;
16
+ }
17
+
18
+ const OWNERSHIP_FORMS = /^(?:3|4|5|144)(?:\/A)?$/i;
19
+ const FUND_FLOW_FORM = /^24F-2NT(?:\/A)?$/i;
20
+
21
+ const isOwnershipForm = (form: string) => OWNERSHIP_FORMS.test(form.trim());
22
+
23
+ /**
24
+ * The document a row is read from. Gloom Cloud returns a Form 3, 4 or 5 as
25
+ * its XML; a Form 144 and a 24F-2NT come as the SEC's rendered page unless
26
+ * asked for their XML, which a separate key keeps apart from the page the
27
+ * open filing shows. Other forms are not read for their rows.
28
+ */
29
+ export function rowSummaryTarget(filing: SecFilingItem): SecFilingItem | null {
30
+ const form = filing.form.trim();
31
+ if (!isOwnershipForm(form) && !FUND_FLOW_FORM.test(form)) return null;
32
+ if (/^[345](?:\/A)?$/i.test(form)) return filing;
33
+ const url = filing.primaryDocumentUrl?.replace(/\/xsl[^/]+\//, "/");
34
+ if (!url || url === filing.primaryDocumentUrl) return null;
35
+ const document = url.split("/").pop() ?? "primary_doc.xml";
36
+ return { ...filing, accessionNumber: `${filing.accessionNumber}:${document}`, primaryDocument: document, primaryDocumentUrl: url, filingUrl: url };
37
+ }
38
+
39
+ function tagText(xml: string, tag: string): string {
40
+ const match = new RegExp(`<(?:[\\w.-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w.-]+:)?${tag}\\s*>`, "i").exec(xml);
41
+ return decodeHtmlEntities((match?.[1] ?? "").replace(/<[^>]*>/g, "").trim());
42
+ }
43
+
44
+ const money = (value: number) => `$${formatCompact(value)}`;
45
+
46
+ /**
47
+ * A Form 144 is a notice that someone means to sell: who, how many shares
48
+ * and what they were worth when it was filed.
49
+ */
50
+ function form144Summary(xml: string): RowSummary | null {
51
+ const name = tagText(xml, "nameOfPersonForWhoseAccountTheSecuritiesAreToBeSold");
52
+ if (!name) return null;
53
+ const shares = Number(tagText(xml, "noOfUnitsSold"));
54
+ const value = Number(tagText(xml, "aggregateMarketValue"));
55
+ const amount = [
56
+ Number.isFinite(shares) && shares > 0 ? `${formatCompact(shares)} shares` : "",
57
+ Number.isFinite(value) && value > 0 ? `(${money(value)})` : "",
58
+ ].filter(Boolean).join(" ");
59
+ return { title: `${name}: proposed sale${amount ? ` of ${amount}` : ""}`, direction: null };
60
+ }
61
+
62
+ /** The reporting owner of a Form 3, which lists holdings and no transactions. */
63
+ function initialOwnershipSummary(xml: string): RowSummary | null {
64
+ const name = tagText(xml, "rptOwnerName");
65
+ return name ? { title: `${name}: initial holdings`, direction: null } : null;
66
+ }
67
+
68
+ const signedMoney = (value: number) => `${value < 0 ? "-" : "+"}${money(Math.abs(value))}`;
69
+
70
+ /**
71
+ * A fund's 24F-2NT: what it sold (created) and redeemed over its fiscal year,
72
+ * and the difference, which is the year's net flow.
73
+ */
74
+ function fundFlowSummary(xml: string): RowSummary | null {
75
+ const sold = Number(tagText(xml, "aggregateSalePriceOfSecuritiesSold"));
76
+ const redeemed = Number(tagText(xml, "aggregatePriceOfSecuritiesRedeemedOrRepurchasedInFiscalYear"));
77
+ if (!tagText(xml, "aggregateSalePriceOfSecuritiesSold") || !Number.isFinite(sold) || !Number.isFinite(redeemed)) return null;
78
+ const yearEnd = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(tagText(xml, "lastDayOfFiscalYear"));
79
+ const net = sold - redeemed;
80
+ return {
81
+ title: `${yearEnd ? `Year to ${yearEnd[3]}-${yearEnd[1]}-${yearEnd[2]}: ` : ""}net flow ${signedMoney(net)} (sold ${money(sold)}, redeemed ${money(redeemed)})`,
82
+ direction: net > 0 ? "buy" : net < 0 ? "sell" : null,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The row text of a filing whose content has been read, or null when it was
88
+ * not read or says nothing a row can use. An insider form with one
89
+ * transaction says it in full; one with several lists their kinds.
90
+ */
91
+ export function rowSummary(form: string, content: string | null | undefined): RowSummary | null {
92
+ if (!content) return null;
93
+ if (FUND_FLOW_FORM.test(form.trim())) return fundFlowSummary(content);
94
+ if (!isOwnershipForm(form)) return null;
95
+ const base = form.trim().replace(/\/A$/i, "");
96
+ if (base === "144") return form144Summary(content);
97
+ const transactions = parseForm4Xml(content);
98
+ const first = transactions[0];
99
+ if (!first) return base === "3" ? initialOwnershipSummary(content) : null;
100
+ const codes = new Set(transactions.map((transaction) => transaction.transactionType));
101
+ const direction = codes.has("P") && !codes.has("S") ? "buy" : codes.has("S") && !codes.has("P") ? "sell" : null;
102
+ if (transactions.length === 1) return { title: `${first.reportedName}: ${buildInsiderTransactionTitle(first)}`, direction };
103
+ const kinds = [...new Set(transactions.map((transaction) => transactionTypeLabel(transaction.transactionType)))];
104
+ return { title: `${first.reportedName}: ${transactions.length} transactions, ${kinds.join(", ")}`, direction };
105
+ }
@@ -395,6 +395,8 @@ export const sectorsModule: PluginModule = {
395
395
  name: "Sector Performance",
396
396
  icon: "S",
397
397
  component: SectorPerformancePane,
398
+ // BI and THEM open this pane: THEM sets the theme param; the ETF lists are preferences.
399
+ identitySettings: [],
398
400
  defaultPosition: "right",
399
401
  defaultMode: "floating",
400
402
  defaultFloatingSize: { width: 82, height: 18 },
@@ -7,7 +7,7 @@ import { mergePriceHistoryIntegrity, pricePointIntegrity, type PriceHistoryInteg
7
7
  import {
8
8
  getExtendedSessionDisplay,
9
9
  getRegularSessionDisplay,
10
- marketStateLabel,
10
+ extendedSessionStateLabel,
11
11
  type ExtendedSession,
12
12
  } from "../../../market-data/market/status";
13
13
  import {
@@ -359,7 +359,7 @@ export function buildSectorColumns(width: number, extendedSessions: readonly Ext
359
359
  const extendedColumns = extendedSessions.map((session): SectorColumn => ({
360
360
  id: session === "PRE" ? "preMarket" : "afterHours",
361
361
  // Named for the session it trades in, as the header labels it.
362
- label: marketStateLabel(session),
362
+ label: extendedSessionStateLabel(session),
363
363
  width: EXTENDED_COLUMN_WIDTH,
364
364
  align: "right",
365
365
  }));
@@ -72,6 +72,7 @@ export const SERIES_INVENTORY: readonly SeriesInventoryEntry[] = [
72
72
  { module: "power", chart: "Interconnection queue capacity and project generation", notInCatalog: "per-item", reason: "Per project and per queue filter." },
73
73
  { module: "power", chart: "European day-ahead power price by country", series: ["day-ahead:*"] },
74
74
  { module: "power", chart: "EU carbon allowance (EUA) auction price", series: ["carbon:*"] },
75
+ { module: "power", chart: "European gas storage by area: % full, TWh and net injection", series: ["gas-storage:*", "gas-storage-twh:*", "gas-injection:*"] },
75
76
  { module: "awards", chart: "A company's government award totals by period", series: ["awards:*"] },
76
77
  { module: "company-kpis", chart: "A company's reported operating metrics", notInCatalog: "per-item", reason: "Each company reports its own metrics, with no shared list to register." },
77
78
  { module: "estimate-revisions", chart: "Consensus estimate for one fiscal period over time", notInCatalog: "per-item", reason: "One fiscal period of one company." },
@@ -11,7 +11,7 @@ import { useThemeColors } from "../../../theme/theme-context";
11
11
 
12
12
  export function Blurred({ children }: { children: ReactNode }) {
13
13
  return (
14
- <Box flexDirection="row" style={{ filter: "blur(5px)", userSelect: "none" }}>
14
+ <Box flexDirection="row" style={{ filter: "blur(5px)", userSelect: "none", WebkitUserSelect: "none" }}>
15
15
  {children}
16
16
  </Box>
17
17
  );
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { DataTableCell, DataTableVisibleRange, PaneFooterSegment } from "../../../components";
3
3
  import { useQuoteEntries } from "../../../market-data/hooks";
4
- import { getRegularSessionDisplay, marketStateColor, marketStateLabel } from "../../../market-data/market/status";
4
+ import { getRegularSessionDisplay, quoteSessionColor, quoteSessionLabel } from "../../../market-data/market/status";
5
5
  import type { InstrumentRef } from "../../../market-data/request-types";
6
6
  import type { QueryEntry } from "../../../market-data/result-types";
7
7
  import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors";
@@ -36,6 +36,23 @@ export interface QuoteBoardOptions {
36
36
  selectedSymbol?: string | null;
37
37
  /** Snapshot poll cadence for on-screen symbols the stream is not carrying. */
38
38
  fallbackIntervalMs?: number;
39
+ /**
40
+ * Paint each listed symbol's cached quote, however old, as the network is
41
+ * asked, and refresh the listed symbols in a few batches at once, each
42
+ * given up after `refreshTimeoutMs` so one slow symbol cannot hold the
43
+ * rest. A row whose refresh fails or times out keeps its cached quote,
44
+ * marked stale.
45
+ */
46
+ cacheFirst?: boolean;
47
+ /**
48
+ * With `cacheFirst`, the symbols the board lists now (after its search and
49
+ * collapsed sections): only these are fetched, and the others keep their
50
+ * cached quote until they are listed. Omitted, every symbol is listed.
51
+ * Keep the reference stable until its contents change.
52
+ */
53
+ listedSymbols?: ReadonlySet<string> | null;
54
+ /** How long a cache-first refresh waits for one batch. */
55
+ refreshTimeoutMs?: number;
39
56
  }
40
57
 
41
58
  /**
@@ -49,6 +66,13 @@ const STREAM_SILENCE_MS = 3 * 60_000;
49
66
  /** The feed answers a new subscription with a snapshot; give it this long before polling around it. */
50
67
  const STREAM_CONNECT_GRACE_MS = 10_000;
51
68
 
69
+ /** A cache-first refresh splits the listed symbols into at most this many batches, sent at once. */
70
+ const QUOTE_REFRESH_BATCHES = 4;
71
+ /** And sends no batch smaller than this, so a short list is one request. */
72
+ const QUOTE_REFRESH_MIN_BATCH = 8;
73
+ /** A batch still unanswered after this long is given up and its rows keep their cached quote. */
74
+ const QUOTE_REFRESH_TIMEOUT_MS = 5_000;
75
+
52
76
  const EMPTY_STATE: BoardQuoteState = { quote: null, loading: false, error: null, stale: false };
53
77
 
54
78
  function errorMessage(value: unknown): string {
@@ -108,6 +132,68 @@ function overlayStreamQuote(base: BoardQuoteState, streamed: Quote | null): Boar
108
132
  return { quote: streamed, loading: base.loading, error: null, stale: streamed.stale === true };
109
133
  }
110
134
 
135
+ /** Quotes for `subset`, one state per symbol; throws only when the whole request fails. */
136
+ async function requestBoardQuotes(
137
+ dataProvider: NonNullable<ReturnType<typeof useAssetData>>,
138
+ subset: readonly string[],
139
+ forceRefresh: boolean,
140
+ signal?: AbortSignal,
141
+ ): Promise<BoardQuoteMap> {
142
+ const next: BoardQuoteMap = new Map();
143
+ if (dataProvider.getQuotesBatch) {
144
+ const results = await dataProvider.getQuotesBatch(
145
+ subset.map((symbol) => ({ symbol, exchange: "" })),
146
+ { forceRefresh, ...(signal ? { signal } : {}) },
147
+ );
148
+ const bySymbol = new Map(results.map((result) => [result.target.symbol, result]));
149
+ for (const symbol of subset) {
150
+ const result = bySymbol.get(symbol);
151
+ next.set(symbol, {
152
+ quote: result?.quote ?? null,
153
+ loading: false,
154
+ error: result?.error ? errorMessage(result.error) : null,
155
+ stale: false,
156
+ });
157
+ }
158
+ return next;
159
+ }
160
+
161
+ const context = forceRefresh ? { cacheMode: "refresh" as const } : undefined;
162
+ await Promise.all(subset.map(async (symbol) => {
163
+ try {
164
+ const quote = await dataProvider.getQuote(symbol, "", context);
165
+ next.set(symbol, { quote, loading: false, error: null, stale: false });
166
+ } catch (error: unknown) {
167
+ next.set(symbol, { quote: null, loading: false, error: errorMessage(error), stale: false });
168
+ }
169
+ }));
170
+ return next;
171
+ }
172
+
173
+ /**
174
+ * Cached quotes painted under a board that is already loading: a row takes
175
+ * one when it has none or an older one. A row whose refresh already came back
176
+ * empty shows the cached quote as stale.
177
+ */
178
+ function paintCachedQuotes(previous: BoardQuoteMap, cached: readonly { target: { symbol: string }; quote: Quote | null }[]): BoardQuoteMap {
179
+ let next: BoardQuoteMap | null = null;
180
+ for (const { target, quote } of cached) {
181
+ const state = previous.get(target.symbol);
182
+ if (!state || !finitePrice(quote)) continue;
183
+ if (state.quote && (state.quote.lastUpdated ?? 0) >= (quote.lastUpdated ?? 0)) continue;
184
+ next ??= new Map(previous);
185
+ next.set(target.symbol, { ...state, quote, stale: !state.loading && (!state.quote || state.stale) });
186
+ }
187
+ return next ?? previous;
188
+ }
189
+
190
+ function inBatches(symbols: readonly string[]): string[][] {
191
+ const size = Math.max(QUOTE_REFRESH_MIN_BATCH, Math.ceil(symbols.length / QUOTE_REFRESH_BATCHES));
192
+ const batches: string[][] = [];
193
+ for (let index = 0; index < symbols.length; index += size) batches.push(symbols.slice(index, index + size));
194
+ return batches;
195
+ }
196
+
111
197
  function sameBoard(left: BoardQuoteMap, right: BoardQuoteMap): boolean {
112
198
  if (left.size !== right.size) return false;
113
199
  const leftEntries = [...left];
@@ -145,6 +231,9 @@ export function useQuoteBoard(symbols: string[], options: QuoteBoardOptions = {}
145
231
  visibleSymbols = null,
146
232
  selectedSymbol = null,
147
233
  fallbackIntervalMs = QUOTE_BOARD_FALLBACK_MS,
234
+ cacheFirst = false,
235
+ listedSymbols = null,
236
+ refreshTimeoutMs = QUOTE_REFRESH_TIMEOUT_MS,
148
237
  } = options;
149
238
  const dataProvider = useAssetData();
150
239
  const appVisible = useAppVisible();
@@ -195,39 +284,7 @@ export function useQuoteBoard(symbols: string[], options: QuoteBoardOptions = {}
195
284
  }
196
285
  if (!dataProvider || subset.length === 0) return;
197
286
 
198
- const loadQuotes = async (): Promise<BoardQuoteMap> => {
199
- const next: BoardQuoteMap = new Map();
200
- if (dataProvider.getQuotesBatch) {
201
- const results = await dataProvider.getQuotesBatch(
202
- subset.map((symbol) => ({ symbol, exchange: "" })),
203
- { forceRefresh },
204
- );
205
- const bySymbol = new Map(results.map((result) => [result.target.symbol, result]));
206
- for (const symbol of subset) {
207
- const result = bySymbol.get(symbol);
208
- next.set(symbol, {
209
- quote: result?.quote ?? null,
210
- loading: false,
211
- error: result?.error ? errorMessage(result.error) : null,
212
- stale: false,
213
- });
214
- }
215
- return next;
216
- }
217
-
218
- const context = forceRefresh ? { cacheMode: "refresh" as const } : undefined;
219
- await Promise.all(subset.map(async (symbol) => {
220
- try {
221
- const quote = await dataProvider.getQuote(symbol, "", context);
222
- next.set(symbol, { quote, loading: false, error: null, stale: false });
223
- } catch (error: unknown) {
224
- next.set(symbol, { quote: null, loading: false, error: errorMessage(error), stale: false });
225
- }
226
- }));
227
- return next;
228
- };
229
-
230
- loadQuotes().then((loaded) => {
287
+ requestBoardQuotes(dataProvider, subset, forceRefresh).then((loaded) => {
231
288
  if (fetchGenRef.current !== gen) return;
232
289
  setSnapshots((prev) => mergeQuotes(prev, loaded));
233
290
  }).catch((error: unknown) => {
@@ -241,14 +298,97 @@ export function useQuoteBoard(symbols: string[], options: QuoteBoardOptions = {}
241
298
  });
242
299
  }, [dataProvider, symbols]);
243
300
 
244
- const refresh = useCallback(() => load(true), [load]);
301
+ const listedRef = useRef(listedSymbols);
302
+ listedRef.current = listedSymbols;
303
+ // Symbols a cache-first board has asked the network for since its last full load.
304
+ const requestedRef = useRef(new Set<string>());
305
+
306
+ /** Refreshes `subset` a few batches at once; a batch that fails or times out keeps its rows' cached quotes. */
307
+ const refreshInBatches = useCallback((subset: readonly string[], forceRefresh: boolean, gen: number) => {
308
+ if (!dataProvider) return;
309
+ for (const batch of inBatches(subset)) {
310
+ const controller = new AbortController();
311
+ let timer: ReturnType<typeof setTimeout> | undefined;
312
+ const timedOut = new Promise<never>((_, reject) => {
313
+ timer = setTimeout(() => {
314
+ controller.abort();
315
+ reject(new Error("Quote refresh timed out"));
316
+ }, refreshTimeoutMs);
317
+ });
318
+ Promise.race([requestBoardQuotes(dataProvider, batch, forceRefresh, controller.signal), timedOut])
319
+ .catch((error: unknown): BoardQuoteMap => new Map(batch.map((symbol) => [
320
+ symbol,
321
+ { quote: null, loading: false, error: errorMessage(error), stale: false },
322
+ ])))
323
+ .then((loaded) => {
324
+ clearTimeout(timer);
325
+ if (fetchGenRef.current !== gen) return;
326
+ setSnapshots((prev) => mergeQuotes(prev, loaded));
327
+ });
328
+ }
329
+ }, [dataProvider, refreshTimeoutMs]);
330
+
331
+ /**
332
+ * Paints `subset`'s cached quotes, however old. Asked after the refresh, so
333
+ * the network request is on its way while the cache is read.
334
+ */
335
+ const paintFromCache = useCallback((subset: readonly string[], gen: number) => {
336
+ if (!dataProvider?.getQuotesBatch || subset.length === 0) return;
337
+ dataProvider.getQuotesBatch(subset.map((symbol) => ({ symbol, exchange: "" })), { cacheOnly: true })
338
+ .then((cached) => {
339
+ if (fetchGenRef.current !== gen) return;
340
+ setSnapshots((prev) => paintCachedQuotes(prev, cached));
341
+ })
342
+ .catch(() => {});
343
+ }, [dataProvider]);
344
+
345
+ /** The cache-first full load: the listed symbols' cached quotes at once, then from the network. */
346
+ const loadCacheFirst = useCallback((forceRefresh: boolean) => {
347
+ fetchGenRef.current += 1;
348
+ const gen = fetchGenRef.current;
349
+ lastLoadAtRef.current = Date.now();
350
+ const listed = listedRef.current;
351
+ const requested = symbols.filter((symbol) => !listed || listed.has(symbol));
352
+ requestedRef.current = new Set(requested);
353
+ setSnapshots((prev) => {
354
+ const next: BoardQuoteMap = new Map();
355
+ for (const symbol of symbols) {
356
+ next.set(symbol, { ...(prev.get(symbol) ?? EMPTY_STATE), loading: !!dataProvider && requestedRef.current.has(symbol) });
357
+ }
358
+ return next;
359
+ });
360
+ if (!dataProvider || requested.length === 0) return;
361
+ refreshInBatches(requested, forceRefresh, gen);
362
+ if (!forceRefresh) paintFromCache(requested, gen);
363
+ }, [dataProvider, paintFromCache, refreshInBatches, symbols]);
364
+
365
+ const refresh = useCallback(() => (cacheFirst ? loadCacheFirst(true) : load(true)), [cacheFirst, load, loadCacheFirst]);
245
366
 
246
367
  useEffect(() => {
247
- load(false);
368
+ if (cacheFirst) loadCacheFirst(false);
369
+ else load(false);
248
370
  return () => {
249
371
  fetchGenRef.current += 1;
250
372
  };
251
- }, [load]);
373
+ }, [cacheFirst, load, loadCacheFirst]);
374
+
375
+ // A symbol listed for the first time since the last full load (a search
376
+ // cleared, a section opened) is fetched then; the others keep what they have.
377
+ const listedKey = listedSymbols ? [...listedSymbols].sort().join("\u001f") : null;
378
+ useEffect(() => {
379
+ if (!cacheFirst || !dataProvider) return;
380
+ const listed = listedRef.current;
381
+ const pending = symbols.filter((symbol) => (!listed || listed.has(symbol)) && !requestedRef.current.has(symbol));
382
+ if (pending.length === 0) return;
383
+ for (const symbol of pending) requestedRef.current.add(symbol);
384
+ setSnapshots((prev) => {
385
+ const next: BoardQuoteMap = new Map(prev);
386
+ for (const symbol of pending) next.set(symbol, { ...(prev.get(symbol) ?? EMPTY_STATE), loading: true });
387
+ return next;
388
+ });
389
+ refreshInBatches(pending, false, fetchGenRef.current);
390
+ paintFromCache(pending, fetchGenRef.current);
391
+ }, [cacheFirst, dataProvider, listedKey, paintFromCache, refreshInBatches, symbols]);
252
392
 
253
393
  const entriesRef = useRef(entries);
254
394
  entriesRef.current = entries;
@@ -508,15 +648,11 @@ export function renderQuoteBoardCell(
508
648
  switch (kind) {
509
649
  case "status": {
510
650
  if (format.sessionText) {
511
- const marketState = quote?.marketState;
512
- return {
513
- text: marketState ? marketStateLabel(marketState) : "—",
514
- color: marketState ? marketStateColor(marketState) : colors.textDim,
515
- };
651
+ return { text: quoteSessionLabel(quote) ?? "—", color: quoteSessionColor(quote) };
516
652
  }
517
653
  const dot = marketStatusDot(quote?.marketState);
518
654
  // The dot is a picture; the export says which session it means.
519
- return { text: dot.char, value: quote?.marketState ? marketStateLabel(quote.marketState) : null, color: dot.color };
655
+ return { text: dot.char, value: quoteSessionLabel(quote), color: dot.color };
520
656
  }
521
657
  case "price":
522
658
  if (!quote || !Number.isFinite(quote.price)) return { text: "—", color: colors.textDim };
@@ -139,7 +139,7 @@ function Placeholder({ row, column, width, native }: { row: number; column: numb
139
139
  const colors = useThemeColors();
140
140
  const fraction = placeholderFraction(row, column);
141
141
  return native
142
- ? <Box style={{ background: colors.textMuted, opacity: 0.35, filter: "blur(3px)", width: `${fraction * 100}%`, height: "0.65em", marginTop: "0.2em", pointerEvents: "none", userSelect: "none" }} />
142
+ ? <Box style={{ background: colors.textMuted, opacity: 0.35, filter: "blur(3px)", width: `${fraction * 100}%`, height: "0.65em", marginTop: "0.2em", pointerEvents: "none", userSelect: "none", WebkitUserSelect: "none" }} />
143
143
  : <Text fg={colors.textMuted} dim>{"░".repeat(Math.max(2, Math.floor(width * fraction)))}</Text>;
144
144
  }
145
145
 
@@ -167,7 +167,7 @@ function FrozenWallSample({ placement, width, rows, native }: { placement: strin
167
167
  <Placeholder row={row} column={0} width={width} native={native} />
168
168
  </Box>)}
169
169
  </Box> :
170
- <Box height={rows + 1} style={native ? { pointerEvents: "none", userSelect: "none" } : undefined}>
170
+ <Box height={rows + 1} style={native ? { pointerEvents: "none", userSelect: "none", WebkitUserSelect: "none" } : undefined}>
171
171
  <DataTableView
172
172
  columns={columns} items={SAMPLE_TICKERS.slice(0, rows)} rootWidth={width} rootHeight={rows + 1}
173
173
  focused={false} keyboardNavigation={false} selection={{ kind: "none" }} sortable={false}