gloomberb 0.17.4 → 0.17.6

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 (384) hide show
  1. package/package.json +3 -1
  2. package/patches/electrobun@1.18.1.patch +80 -0
  3. package/src/api-client/chat.ts +9 -0
  4. package/src/api-client/commodity-balances.ts +77 -0
  5. package/src/api-client/data.ts +35 -3
  6. package/src/api-client/earnings.ts +10 -0
  7. package/src/api-client/index.ts +5 -0
  8. package/src/api-client/insider-signals.ts +75 -0
  9. package/src/api-client/market-discovery.ts +1 -1
  10. package/src/api-client/normalizers.ts +1 -0
  11. package/src/api-client/paths.ts +9 -0
  12. package/src/api-client/research-activity.ts +10 -3
  13. package/src/api-client/socket.ts +10 -0
  14. package/src/api-client/types.ts +25 -1
  15. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -1
  16. package/src/app/runtime/broker-import.ts +23 -21
  17. package/src/app/runtime/plugin-bindings.ts +13 -7
  18. package/src/app/runtime/update.ts +64 -4
  19. package/src/app.tsx +2 -0
  20. package/src/architecture/core-builtin-allowlist.json +2 -10
  21. package/src/ask-gloom/pane-context.ts +11 -1
  22. package/src/brokers/broker-portfolio-sync.ts +66 -0
  23. package/src/brokers/sync-broker-instance.ts +75 -41
  24. package/src/cli/commands/market.ts +101 -34
  25. package/src/cli/commands/options-chain.ts +20 -6
  26. package/src/cli/commands/overview.ts +54 -13
  27. package/src/cli/commands/search.ts +1 -1
  28. package/src/cli/commands/system.ts +19 -1
  29. package/src/cli/commands/ticker.ts +24 -4
  30. package/src/cli/context.ts +13 -0
  31. package/src/cli/errors.ts +15 -2
  32. package/src/cli/fx-pair.ts +77 -0
  33. package/src/cli/glossary.ts +34 -4
  34. package/src/cli/history-rows.ts +25 -11
  35. package/src/cli/index.ts +30 -12
  36. package/src/cli/listing-arg.ts +61 -138
  37. package/src/cli/listing-suggestion.ts +3 -21
  38. package/src/cli/pane-functions/catalog.ts +50 -0
  39. package/src/cli/pane-functions/freshness.ts +44 -9
  40. package/src/cli/pane-functions/function-help.ts +9 -9
  41. package/src/cli/pane-functions/headless.ts +19 -7
  42. package/src/cli/pane-functions/index.ts +63 -5
  43. package/src/cli/pane-functions/market-session.ts +18 -3
  44. package/src/cli/pane-functions/resolver.ts +29 -8
  45. package/src/cli/pane-functions/screenshot.ts +2 -0
  46. package/src/cli/report-tables.ts +11 -4
  47. package/src/cli/result.ts +2 -0
  48. package/src/cli/series-search.ts +6 -1
  49. package/src/cli/ticker-resolution.ts +1 -2
  50. package/src/cli/unknown-command.ts +18 -0
  51. package/src/components/chart/composite/composite-chart.tsx +80 -19
  52. package/src/components/chart/composite/format.ts +17 -0
  53. package/src/components/chart/composite/legend.tsx +26 -15
  54. package/src/components/chart/composite/panel-labels.ts +38 -0
  55. package/src/components/chart/composite/panel-series.ts +1 -0
  56. package/src/components/chart/composite/panel-surface.tsx +25 -7
  57. package/src/components/chart/composite/price-axis-labels.tsx +16 -3
  58. package/src/components/chart/composite/scene.ts +60 -2
  59. package/src/components/chart/composite/series-axis-tags.tsx +67 -0
  60. package/src/components/chart/composite/tools.ts +2 -2
  61. package/src/components/chart/composite/types.ts +28 -0
  62. package/src/components/chart/core/view-reset.ts +46 -0
  63. package/src/components/command-bar/commands/collection.ts +1 -1
  64. package/src/components/command-bar/commands/direct/index.ts +48 -1
  65. package/src/components/command-bar/commands/registry.ts +8 -0
  66. package/src/components/command-bar/list/model.ts +7 -0
  67. package/src/components/command-bar/pane-templates/workflow.ts +5 -0
  68. package/src/components/command-bar/panel/prompt-binding.ts +8 -0
  69. package/src/components/command-bar/routes/root/command-items.ts +17 -2
  70. package/src/components/command-bar/routes/root/pick-prefetch.ts +38 -0
  71. package/src/components/command-bar/routes/root/provider-search.ts +6 -0
  72. package/src/components/command-bar/routes/root/runtime.ts +3 -0
  73. package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -1
  74. package/src/components/command-bar/routes/root/shortcut-items.ts +1 -1
  75. package/src/components/command-bar/routes/ticker-search/actions.ts +34 -0
  76. package/src/components/command-bar/surface/index.tsx +7 -1
  77. package/src/components/command-bar/workflow/collection-actions.ts +6 -5
  78. package/src/components/command-bar/workflow/ops.ts +8 -5
  79. package/src/components/command-bar/workflow/pane-reuse.ts +3 -2
  80. package/src/components/command-bar/workflow/tickers.ts +1 -1
  81. package/src/components/form-modal/confirm.tsx +1 -0
  82. package/src/components/form-modal/request.ts +2 -0
  83. package/src/components/layout/detached-pane-shell.tsx +6 -1
  84. package/src/components/layout/pane/content.tsx +11 -8
  85. package/src/components/layout/pane/sidebar.tsx +24 -11
  86. package/src/components/layout/pane/ticker-fate-gate.tsx +56 -0
  87. package/src/components/layout/shell/index.tsx +5 -1
  88. package/src/components/layout/shell/menu.ts +8 -0
  89. package/src/components/layout/shell/pane/capture.ts +29 -12
  90. package/src/components/onboarding/wizard-model.ts +1 -1
  91. package/src/components/onboarding/wizard-positions.ts +18 -10
  92. package/src/components/ticker/badge/format.ts +64 -15
  93. package/src/components/ticker/badge/index.tsx +32 -11
  94. package/src/components/ticker/badge/list.tsx +4 -3
  95. package/src/components/ticker/badge/text.tsx +2 -2
  96. package/src/components/ticker/list-table-view.tsx +8 -1
  97. package/src/components/ui/confirm-dialog.tsx +30 -4
  98. package/src/components/ui/data-table/opentui/index.tsx +61 -20
  99. package/src/components/ui/data-table/opentui/model.ts +104 -0
  100. package/src/components/ui/fields.tsx +5 -0
  101. package/src/components/ui/inline-quick-add.tsx +4 -1
  102. package/src/components/ui/query-bar-keys.tsx +29 -5
  103. package/src/components/ui/query-bar.tsx +29 -4
  104. package/src/components/ui/windowed-list.tsx +27 -0
  105. package/src/data/config/store/normalize.ts +8 -0
  106. package/src/docs/snapshot.json +248 -5
  107. package/src/i18n/es.ts +3 -1
  108. package/src/i18n/ja.ts +3 -1
  109. package/src/i18n/ko.ts +3 -1
  110. package/src/i18n/zh-cn.ts +3 -1
  111. package/src/i18n/zh-tw.ts +3 -1
  112. package/src/layout-marketplace/cloud.ts +16 -5
  113. package/src/layout-marketplace/gallery.tsx +4 -4
  114. package/src/layout-marketplace/linked-sync.ts +9 -3
  115. package/src/layout-marketplace/linked.ts +24 -3
  116. package/src/layout-marketplace/model.ts +39 -4
  117. package/src/market-data/coordinator/events.ts +5 -1
  118. package/src/market-data/coordinator/index.ts +38 -1
  119. package/src/market-data/coordinator/quotes.ts +4 -0
  120. package/src/market-data/futures-contract.ts +53 -0
  121. package/src/market-data/hooks.tsx +225 -30
  122. package/src/market-data/market/bar-period.ts +71 -0
  123. package/src/market-data/market/format.ts +14 -2
  124. package/src/market-data/market/freshness.ts +28 -1
  125. package/src/market-data/market/status.ts +152 -13
  126. package/src/market-data/model-dividend-yield.ts +76 -0
  127. package/src/market-data/quotes/freshness.ts +30 -1
  128. package/src/{plugins/builtin/portfolio-list → market-data/quotes}/live-valuation.ts +6 -6
  129. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  130. package/src/plugins/builtin/analytics/metrics.ts +8 -4
  131. package/src/plugins/builtin/analytics/risk-headless.ts +12 -5
  132. package/src/plugins/builtin/analytics/risk-model.ts +74 -15
  133. package/src/plugins/builtin/analytics/risk-pane.tsx +6 -6
  134. package/src/plugins/builtin/broker-manager/detail.tsx +2 -2
  135. package/src/plugins/builtin/broker-manager/model.ts +2 -2
  136. package/src/plugins/builtin/builtin-plugin-meta.ts +0 -1
  137. package/src/plugins/builtin/chart-composer/editor/controller.ts +14 -10
  138. package/src/plugins/builtin/chart-composer/formula.ts +233 -0
  139. package/src/plugins/builtin/chart-composer/headless.ts +27 -2
  140. package/src/plugins/builtin/chart-composer/index.tsx +3 -2
  141. package/src/plugins/builtin/chart-composer/pane.tsx +41 -10
  142. package/src/plugins/builtin/chart-composer/presets.ts +91 -2
  143. package/src/plugins/builtin/chart-composer/quick-add.tsx +9 -2
  144. package/src/plugins/builtin/chart-composer/series-catalog.ts +44 -11
  145. package/src/plugins/builtin/chart-composer/series-expression.ts +1 -1
  146. package/src/plugins/builtin/chart-composer/settings.ts +16 -4
  147. package/src/plugins/builtin/chart-composer/studies.ts +1 -1
  148. package/src/plugins/builtin/chat/content/channel-navigation.ts +17 -6
  149. package/src/plugins/builtin/chat/content/footer.tsx +5 -2
  150. package/src/plugins/builtin/chat/content/index.tsx +12 -5
  151. package/src/plugins/builtin/chat/content/mentions.ts +50 -21
  152. package/src/plugins/builtin/chat/content/scroll.ts +12 -5
  153. package/src/plugins/builtin/chat/content/snapshot.ts +11 -0
  154. package/src/plugins/builtin/chat/content/transcript.tsx +48 -37
  155. package/src/plugins/builtin/chat/content/whole-message-copy.ts +44 -2
  156. package/src/plugins/builtin/chat/controller/index.ts +88 -17
  157. package/src/plugins/builtin/chat/controller/mention-candidates.ts +104 -0
  158. package/src/plugins/builtin/chat/controller/messages.ts +14 -2
  159. package/src/plugins/builtin/chat/controller/realtime.ts +9 -0
  160. package/src/plugins/builtin/chat/controller/state.ts +25 -0
  161. package/src/plugins/builtin/chat/controller/unread-people.ts +240 -0
  162. package/src/plugins/builtin/chat/controller/view.ts +4 -0
  163. package/src/plugins/builtin/chat/conversation-recency.ts +73 -0
  164. package/src/plugins/builtin/chat/conversations.ts +12 -5
  165. package/src/plugins/builtin/chat/index.tsx +1 -1
  166. package/src/plugins/builtin/chat/layout.ts +23 -5
  167. package/src/plugins/builtin/chat/message/desktop.tsx +2 -0
  168. package/src/plugins/builtin/chat/share/flow.tsx +18 -9
  169. package/src/plugins/builtin/chat/sidebar-rows.ts +18 -5
  170. package/src/plugins/builtin/chat/sidebar.tsx +6 -1
  171. package/src/plugins/builtin/chat/unread-badge.ts +38 -7
  172. package/src/plugins/builtin/cloud/askg/manifest.ts +4 -3
  173. package/src/plugins/builtin/cloud/askg/pane-attachment.ts +2 -3
  174. package/src/plugins/builtin/cloud/terminal-relay/manifest.ts +16 -5
  175. package/src/plugins/builtin/commodities-energy/index.tsx +23 -4
  176. package/src/plugins/builtin/commodity-balances/client.ts +51 -193
  177. package/src/plugins/builtin/commodity-balances/headless.ts +10 -3
  178. package/src/plugins/builtin/commodity-balances/model.ts +5 -180
  179. package/src/plugins/builtin/commodity-balances/pane.tsx +3 -0
  180. package/src/plugins/builtin/custom-view/index.tsx +1 -1
  181. package/src/plugins/builtin/custom-view/loader.ts +57 -7
  182. package/src/plugins/builtin/custom-view/sources.ts +146 -0
  183. package/src/plugins/builtin/custom-view/view-spec.ts +37 -1
  184. package/src/plugins/builtin/earnings/board-model.ts +33 -3
  185. package/src/plugins/builtin/earnings/board-table.ts +59 -14
  186. package/src/plugins/builtin/earnings/board.tsx +36 -3
  187. package/src/plugins/builtin/earnings/headless.ts +24 -5
  188. package/src/plugins/builtin/earnings/history-model.ts +9 -1
  189. package/src/plugins/builtin/earnings/history-table.ts +16 -5
  190. package/src/plugins/builtin/earnings/history.tsx +40 -10
  191. package/src/plugins/builtin/earnings/index.tsx +3 -1
  192. package/src/plugins/builtin/earnings/no-earnings.ts +52 -0
  193. package/src/plugins/builtin/econ-statistics/detail.tsx +4 -1
  194. package/src/plugins/builtin/econ-statistics/headless.ts +5 -0
  195. package/src/plugins/builtin/econ-statistics/pane.tsx +1 -1
  196. package/src/plugins/builtin/econ-statistics/view.ts +4 -0
  197. package/src/plugins/builtin/futures/model.ts +5 -4
  198. package/src/plugins/builtin/futures/series.ts +38 -1
  199. package/src/plugins/builtin/futures-curve/client.ts +4 -4
  200. package/src/plugins/builtin/futures-curve/headless.ts +33 -8
  201. package/src/plugins/builtin/futures-curve/index.tsx +4 -4
  202. package/src/plugins/builtin/futures-curve/model.ts +109 -6
  203. package/src/plugins/builtin/futures-curve/pane.tsx +24 -13
  204. package/src/plugins/builtin/insider/headless.ts +74 -4
  205. package/src/plugins/builtin/insider/index.tsx +99 -5
  206. package/src/plugins/builtin/insider/signals-client.ts +69 -0
  207. package/src/plugins/builtin/insider/signals-model.ts +146 -0
  208. package/src/plugins/builtin/insider/signals.tsx +176 -0
  209. package/src/plugins/builtin/iv-history/client.ts +41 -2
  210. package/src/plugins/builtin/iv-history/headless.ts +24 -16
  211. package/src/plugins/builtin/iv-history/screen-pane.tsx +25 -7
  212. package/src/plugins/builtin/jodi/client.ts +42 -205
  213. package/src/plugins/builtin/jodi/headless.ts +13 -4
  214. package/src/plugins/builtin/jodi/model.ts +18 -43
  215. package/src/plugins/builtin/jodi/pane.tsx +5 -0
  216. package/src/plugins/builtin/kelly-sizer/headless.ts +1 -1
  217. package/src/plugins/builtin/kelly-sizer/history.ts +5 -3
  218. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  219. package/src/plugins/builtin/macro-day/release-table.ts +3 -3
  220. package/src/plugins/builtin/market-movers/client.ts +32 -6
  221. package/src/plugins/builtin/market-movers/headless.ts +7 -2
  222. package/src/plugins/builtin/market-movers/model.ts +64 -3
  223. package/src/plugins/builtin/market-movers/screener.ts +20 -9
  224. package/src/plugins/builtin/market-valuation/detail.tsx +7 -2
  225. package/src/plugins/builtin/market-valuation/headless.ts +17 -4
  226. package/src/plugins/builtin/market-valuation/pane.tsx +7 -3
  227. package/src/plugins/builtin/market-valuation/view.ts +10 -0
  228. package/src/plugins/builtin/mna/model.ts +2 -1
  229. package/src/plugins/builtin/options-positioning/client.ts +10 -0
  230. package/src/plugins/builtin/options-positioning/headless.ts +88 -13
  231. package/src/plugins/builtin/options-positioning/index.tsx +2 -2
  232. package/src/plugins/builtin/options-positioning/model.ts +47 -0
  233. package/src/plugins/builtin/options-positioning/pane.tsx +55 -26
  234. package/src/plugins/builtin/options-scenario/charts.tsx +13 -4
  235. package/src/plugins/builtin/options-scenario/client.ts +111 -17
  236. package/src/plugins/builtin/options-scenario/collar.ts +141 -0
  237. package/src/plugins/builtin/options-scenario/csp.ts +1 -1
  238. package/src/plugins/builtin/options-scenario/freshness.ts +27 -0
  239. package/src/plugins/builtin/options-scenario/headless.ts +28 -12
  240. package/src/plugins/builtin/options-scenario/index.tsx +1 -1
  241. package/src/plugins/builtin/options-scenario/model.ts +55 -5
  242. package/src/plugins/builtin/options-scenario/pane.tsx +24 -5
  243. package/src/plugins/builtin/perps/client.ts +77 -6
  244. package/src/plugins/builtin/perps/headless.ts +25 -14
  245. package/src/plugins/builtin/perps/index.tsx +1 -1
  246. package/src/plugins/builtin/perps/model.ts +32 -0
  247. package/src/plugins/builtin/perps/pane.tsx +5 -4
  248. package/src/plugins/builtin/portfolio-list/allocation.ts +9 -4
  249. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +39 -20
  250. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
  251. package/src/plugins/builtin/portfolio-list/cli/shared.ts +15 -7
  252. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +1 -1
  253. package/src/plugins/builtin/portfolio-list/column-values.ts +4 -3
  254. package/src/plugins/builtin/portfolio-list/command-bar.ts +1 -1
  255. package/src/plugins/builtin/portfolio-list/headless.ts +10 -3
  256. package/src/plugins/builtin/portfolio-list/pane/index.tsx +36 -4
  257. package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
  258. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +35 -6
  259. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  260. package/src/plugins/builtin/portfolio-list/settings.ts +22 -0
  261. package/src/plugins/builtin/portfolio-list/since-last-seen.ts +197 -0
  262. package/src/plugins/builtin/portfolio-list/summary/totals.ts +1 -1
  263. package/src/plugins/builtin/portfolio-list/table.tsx +14 -2
  264. package/src/plugins/builtin/rate-path/model.ts +1 -1
  265. package/src/plugins/builtin/rate-path/pane.tsx +2 -1
  266. package/src/plugins/builtin/research/corporate-actions-pane.tsx +14 -5
  267. package/src/plugins/builtin/research/event-model.ts +41 -49
  268. package/src/plugins/builtin/research/event-sources.ts +1 -1
  269. package/src/plugins/builtin/research/headless.ts +1 -1
  270. package/src/plugins/builtin/research/relative-valuation-model.ts +6 -0
  271. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
  272. package/src/plugins/builtin/reverse-dcf/headless.ts +25 -9
  273. package/src/plugins/builtin/reverse-dcf/index.tsx +4 -1
  274. package/src/plugins/builtin/reverse-dcf/model.ts +39 -5
  275. package/src/plugins/builtin/reverse-dcf/pane.tsx +16 -6
  276. package/src/plugins/builtin/sectors/client.ts +46 -19
  277. package/src/plugins/builtin/sectors/sector-model.ts +22 -10
  278. package/src/plugins/builtin/shared/earnings-report.ts +48 -1
  279. package/src/plugins/builtin/shared/history-span.ts +55 -0
  280. package/src/plugins/builtin/shared/report-freshness.ts +20 -11
  281. package/src/plugins/builtin/shared/series-detail-chart.tsx +5 -2
  282. package/src/plugins/builtin/shared/series-list-detail.tsx +7 -3
  283. package/src/plugins/builtin/shared/ticker-surface.ts +16 -2
  284. package/src/plugins/builtin/shared/use-quote-board.ts +12 -5
  285. package/src/plugins/builtin/themes/headless.ts +15 -2
  286. package/src/plugins/builtin/thirteenf/api.ts +60 -43
  287. package/src/plugins/builtin/thirteenf/data.ts +76 -35
  288. package/src/plugins/builtin/thirteenf/headless.ts +2 -0
  289. package/src/plugins/builtin/thirteenf/pane.tsx +10 -2
  290. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +85 -17
  291. package/src/plugins/builtin/ticker-detail/data-panes/history-bars.ts +92 -0
  292. package/src/plugins/builtin/ticker-detail/data-panes/returns-pane.tsx +26 -8
  293. package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -2
  294. package/src/plugins/builtin/ticker-detail/headless.ts +95 -22
  295. package/src/plugins/builtin/ticker-detail/overview/model.ts +1 -1
  296. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +1 -1
  297. package/src/plugins/builtin/world-venue-map/headless.ts +17 -3
  298. package/src/plugins/builtin/yield-curve/curves.ts +15 -1
  299. package/src/plugins/builtin/yield-curve/headless.ts +17 -7
  300. package/src/plugins/builtin/yield-curve/history.ts +2 -0
  301. package/src/plugins/builtin/yield-curve/index.tsx +23 -18
  302. package/src/plugins/builtin/yield-curve/series.ts +19 -9
  303. package/src/plugins/builtin/yield-curve/treasury-data.ts +2 -0
  304. package/src/plugins/builtin/yield-curve/world.ts +4 -0
  305. package/src/plugins/registry/host-actions.ts +3 -0
  306. package/src/{plugins/builtin/portfolio-list → portfolio}/mutations.ts +6 -6
  307. package/src/portfolio/valuation-basis.ts +4 -21
  308. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  309. package/src/remote/controller.ts +27 -7
  310. package/src/remote/schema.ts +25 -9
  311. package/src/renderers/browser/app-services.ts +7 -1
  312. package/src/renderers/browser/config-host.ts +55 -2
  313. package/src/renderers/browser/first-screen.ts +193 -0
  314. package/src/renderers/browser/main.tsx +59 -7
  315. package/src/renderers/cloudflare/http-proxy.ts +21 -13
  316. package/src/renderers/dom/desktop/controls.tsx +2 -0
  317. package/src/renderers/dom/desktop/query-bar.tsx +1 -1
  318. package/src/renderers/dom/dom-ui-host.tsx +2 -0
  319. package/src/renderers/dom/windowed-list.tsx +201 -0
  320. package/src/renderers/dom/windowed-rows.ts +65 -0
  321. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
  322. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +3 -7
  323. package/src/renderers/electrobun/bun/desktop/http-stream.ts +21 -6
  324. package/src/renderers/electrobun/bun/desktop/update.ts +132 -14
  325. package/src/renderers/electrobun/shared/protocol.ts +7 -4
  326. package/src/renderers/electrobun/view/http-fetch.ts +12 -1
  327. package/src/renderers/electrobun/view/update-host.ts +2 -2
  328. package/src/shares/bug-report-dialog.tsx +162 -0
  329. package/src/shares/bug-report-flow.tsx +84 -0
  330. package/src/shares/bug-report.ts +42 -0
  331. package/src/shares/capture-flow.ts +19 -0
  332. package/src/shares/capture-preview.tsx +129 -0
  333. package/src/shares/chat.ts +10 -1
  334. package/src/shares/dialog.tsx +16 -91
  335. package/src/shares/flow.tsx +12 -16
  336. package/src/shares/personal-pane.ts +35 -1
  337. package/src/sources/listing-symbols.ts +8 -4
  338. package/src/state/app/context/index.tsx +13 -0
  339. package/src/state/app/tab-cursor.ts +85 -0
  340. package/src/sync/core-contributors.ts +206 -59
  341. package/src/telemetry/usage-counts.ts +16 -0
  342. package/src/test-support/config-store.ts +31 -0
  343. package/src/test-support/moved-symbols.ts +64 -0
  344. package/src/theme/colors.ts +30 -0
  345. package/src/tickers/listing-fate.ts +242 -0
  346. package/src/tickers/open-target.ts +61 -11
  347. package/src/tickers/search/index.ts +45 -3
  348. package/src/tickers/search/ranking.ts +19 -2
  349. package/src/tickers/search/types.ts +3 -0
  350. package/src/tickers/search/upsert.ts +22 -7
  351. package/src/tickers/ticker-changes.ts +26 -9
  352. package/src/time-series/bar-spacing.ts +19 -0
  353. package/src/time-series/chart-data.ts +37 -13
  354. package/src/time-series/field-catalog.ts +5 -2
  355. package/src/time-series/forward-valuation.ts +10 -7
  356. package/src/time-series/fundamentals.ts +49 -11
  357. package/src/time-series/hooks.ts +2 -0
  358. package/src/time-series/live-bars.ts +3 -1
  359. package/src/time-series/parsed-history-cache.ts +2 -0
  360. package/src/time-series/price-units.ts +69 -0
  361. package/src/time-series/resolve.ts +71 -11
  362. package/src/time-series/session-bar.ts +138 -0
  363. package/src/time-series/spec.ts +20 -3
  364. package/src/time-series/studies.ts +211 -12
  365. package/src/time-series/transforms.ts +2 -0
  366. package/src/time-series/types.ts +11 -1
  367. package/src/time-series/valuation-price.ts +13 -0
  368. package/src/types/config.ts +24 -1
  369. package/src/types/financials.ts +2 -0
  370. package/src/types/headless.ts +27 -0
  371. package/src/types/plugin.ts +7 -0
  372. package/src/ui/host.tsx +5 -0
  373. package/src/updater/channel.ts +51 -0
  374. package/src/updater/github-releases.ts +85 -16
  375. package/src/updater/index.ts +128 -17
  376. package/src/utils/asset-category.ts +20 -0
  377. package/src/utils/datetime-format.ts +7 -1
  378. package/src/utils/decimal-comma.ts +83 -3
  379. package/src/utils/fundamentals.ts +7 -2
  380. package/src/utils/http-proxy-response.ts +66 -9
  381. package/src/utils/plugin-proxy-hosts.json +1 -5
  382. package/src/utils/semver.ts +47 -2
  383. package/src/utils/typo-distance.ts +22 -0
  384. package/src/plugins/builtin/shared/zip-text.ts +0 -46
@@ -0,0 +1,242 @@
1
+ import type { DataProvider } from "../types/data-provider";
2
+ import type { Quote } from "../types/financials";
3
+ import type { TickerChanges } from "../types/ticker-changes";
4
+ import {
5
+ describeTickerChange,
6
+ movedAwayChange,
7
+ quotedListing,
8
+ startTickerChangeLookup,
9
+ tickerChangeLine,
10
+ type CurrentListing,
11
+ type MovedTickerChange,
12
+ type TickerChange,
13
+ type TickerChangeLookup,
14
+ } from "./ticker-changes";
15
+ import { isQuoteBeforeLatestSession, isQuoteStaleForCurrentSession } from "../market-data/quotes/freshness";
16
+ import { formatMarketPriceWithCurrency, quoteFormatOptions } from "../market-data/market/format";
17
+ import { canonicalExchange, isUsListingExchange, parsePublicTickerKey, resolveExchangeTimeZone } from "../utils/exchanges";
18
+ import { zonedDateKey } from "../utils/zoned-date-time";
19
+
20
+ /*
21
+ * What became of a symbol, read the way `quote` reads it: the listing's own
22
+ * quote says whether and when it stopped trading, and the symbol's ticker
23
+ * changes say whether its company moved. The CLI commands and the app's panes
24
+ * both decide from here what a symbol has to serve.
25
+ */
26
+
27
+ // A quote this much older than now, and stale, is a listing that stopped trading, not a quiet day.
28
+ const ENDED_AFTER_MS = 183 * 24 * 60 * 60_000;
29
+
30
+ /**
31
+ * The exchange-local date a listing last traded, when its quote is stale and
32
+ * that was over half a year ago (TWTR's 2022-10-27); null for anything newer.
33
+ */
34
+ function endedTradingDate(quote: Quote, now = Date.now()): string | null {
35
+ const last = Math.max(...[quote.lastTradeTime, quote.lastUpdated].filter((time): time is number => Number.isFinite(time)));
36
+ if (!(last > 0) || now - last < ENDED_AFTER_MS || !isQuoteStaleForCurrentSession(quote, now)) return null;
37
+ const zone = resolveExchangeTimeZone(quote.listingExchangeName || quote.exchangeName) ?? "UTC";
38
+ return zonedDateKey(last, zone);
39
+ }
40
+
41
+ /** What trades under a symbol now, from its quote: nothing when it has none or stopped long ago. */
42
+ export function tradingListing(quote: Quote | null | undefined, now = Date.now()): CurrentListing | null {
43
+ return quote && !endedTradingDate(quote, now) ? quotedListing(quote) : null;
44
+ }
45
+
46
+ /** "TWTR: no longer trading since 2022-10-27 (last price $53.86)", for a quote that stopped long ago. */
47
+ export function noLongerTradingNote(label: string, quote: Quote, now = Date.now()): string | null {
48
+ const date = endedTradingDate(quote, now);
49
+ if (!date) return null;
50
+ const price = formatMarketPriceWithCurrency(quote.price, quote.currency, quoteFormatOptions(quote));
51
+ return `${label}: no longer trading since ${date} (last price ${price})`;
52
+ }
53
+
54
+ /**
55
+ * What became of a symbol, from what `quote` reads it by: the listing's own
56
+ * quote, which says when it stopped trading, and the symbol's ticker changes
57
+ * beside that quote (FB became META).
58
+ */
59
+ export interface ListingFate {
60
+ /** The exchange-local date the listing last traded, when its quote stopped long ago (TWTR's 2022-10-27). */
61
+ endedOn: string | null;
62
+ /** The symbol's change as `quote` reads it; null when it has none, or was read on a listing abroad. */
63
+ change: TickerChange | null;
64
+ /**
65
+ * The line a command with nothing to serve for the symbol says: `quote`'s
66
+ * change line when its company left it ("FB now lists ...; Meta Platforms
67
+ * trades as META (changed 2022-06-09)"), else, for a listing that stopped,
68
+ * "TWTR stopped trading 2022-10-27; no history or options served". Null
69
+ * for a symbol that trades as it always has.
70
+ */
71
+ note: string | null;
72
+ /**
73
+ * The change when the symbol's company trades under another symbol now
74
+ * and its own quote shows nothing new listed under it (`movedAwayChange`):
75
+ * the old line, stopped or named for that company. Whatever data comes
76
+ * back under the symbol is then another security's (a listing abroad with
77
+ * the same letters, or the new symbol's bars under the old one), so a
78
+ * command or pane serves none and names the new symbol. Null without a
79
+ * quote to tell by.
80
+ */
81
+ moved: MovedTickerChange | null;
82
+ }
83
+
84
+ /** Nothing became of the symbol, or nothing could be told. */
85
+ export const NO_FATE: ListingFate = { endedOn: null, change: null, note: null, moved: null };
86
+ /** How long a fate waits for a change lookup it started late, or for a stopped line's. */
87
+ export const LATE_CHANGE_WAIT_MS = 2_000;
88
+
89
+ // The zones of the US exchanges; a venue in another is abroad.
90
+ const US_TIME_ZONES = new Set(["America/New_York", "America/Chicago"]);
91
+
92
+ /**
93
+ * "SXR8 -> XETRA" for a bare symbol the data service read on its home listing
94
+ * abroad, as it reads a symbol with no US listing whose listings belong to one
95
+ * company or fund: a row that shows the symbol as typed says which listing it
96
+ * is. Null for a named or saved venue, and for a venue the app does not place
97
+ * outside the US (a US exchange, OTC, a futures exchange).
98
+ */
99
+ export function bareListingNote(
100
+ listing: { symbol: string; request: { exchange: string } },
101
+ quote: Pick<Quote, "listingExchangeName" | "exchangeName"> | null | undefined,
102
+ ): string | null {
103
+ if (listing.request.exchange || !quote || !/^[A-Z0-9]{1,10}$/.test(listing.symbol)) return null;
104
+ const venue = canonicalExchange(quote.listingExchangeName || quote.exchangeName);
105
+ const zone = resolveExchangeTimeZone(venue);
106
+ return zone && !US_TIME_ZONES.has(zone) && !isUsListingExchange(venue) ? `${listing.symbol} -> ${venue}` : null;
107
+ }
108
+
109
+ /** `ListingFate` from a listing's quote (null when it has none) and its ticker changes. */
110
+ export function describeListingFate(
111
+ listing: { symbol: string; exchange?: string | null },
112
+ quote: Quote | null,
113
+ changes: TickerChanges | null,
114
+ now = Date.now(),
115
+ ): ListingFate {
116
+ const symbol = listing.symbol.trim().toUpperCase();
117
+ const endedOn = quote ? endedTradingDate(quote, now) : null;
118
+ // A bare symbol read on a listing abroad (SXR8 -> XETRA) is not the US symbol the changes are of.
119
+ const abroad = !!quote && !!bareListingNote({ symbol, request: { exchange: listing.exchange ?? "" } }, quote);
120
+ const change = abroad ? null : describeTickerChange(symbol, changes, tradingListing(quote, now));
121
+ const note = tickerChangeLine(change) ?? (endedOn ? `${symbol} stopped trading ${endedOn}; no history or options served` : null);
122
+ return { endedOn, change, note, moved: quote ? movedAwayChange(change) : null };
123
+ }
124
+
125
+ /**
126
+ * Whether a quote's line stopped: a whole session traded without it, or it
127
+ * ended long ago. Only such a line can be a company that moved away, so only
128
+ * its change lookup is waited for.
129
+ */
130
+ function mayHaveMoved(quote: Quote | null, now = Date.now()): boolean {
131
+ return !!quote && (isQuoteBeforeLatestSession(quote, now) || endedTradingDate(quote, now) != null);
132
+ }
133
+
134
+ /**
135
+ * `describeListingFate` for a listing: its quote is `quote` when the command
136
+ * has it, else loaded; its changes come from `changes` when the command started
137
+ * that lookup beside its own request, else from one started here. Fails soft:
138
+ * a source that does not answer says nothing became of it.
139
+ */
140
+ export async function loadListingFate(
141
+ listing: { symbol: string; exchange?: string | null; request?: { symbol: string; exchange: string } },
142
+ dataProvider: Pick<DataProvider, "getQuote" | "getTickerChanges">,
143
+ options: { quote?: Quote | null | Promise<Quote | null>; changes?: TickerChangeLookup } = {},
144
+ ): Promise<ListingFate> {
145
+ const request = listing.request ?? { symbol: listing.symbol, exchange: listing.exchange ?? "" };
146
+ const lookup = options.changes ?? startTickerChangeLookup(dataProvider, listing.symbol, listing.exchange);
147
+ try {
148
+ const quote = await (options.quote !== undefined
149
+ ? options.quote
150
+ : dataProvider.getQuote(request.symbol, request.exchange).catch(() => null));
151
+ // A lookup started beside the command's request gets a short grace once that is ready, so a slow
152
+ // one never holds a symbol that trades. A line that stopped is waited for: only its company can
153
+ // have moved, and then the answer decides whether the command serves anything at all.
154
+ const changes = await lookup.settle(!options.changes || mayHaveMoved(quote) ? LATE_CHANGE_WAIT_MS : undefined);
155
+ return describeListingFate(listing, quote, changes);
156
+ } catch {
157
+ return NO_FATE;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The lines a command says about a symbol's fate beside what it served. With
163
+ * nothing served, `fate.note`. With data, `quote`'s change line when the
164
+ * symbol's company left it (FB's bars are the fund listed there now), and, for
165
+ * bars from before the symbol's holder took it (META's from 2021), that they
166
+ * are from its old symbol: "Includes FB-era bars: META was FB until 2022-06-09".
167
+ */
168
+ export function listingFateNotes(fate: ListingFate, served: { served: boolean; firstDate?: string | null }): string[] {
169
+ if (!served.served) return fate.note ? [fate.note] : [];
170
+ const { change } = fate;
171
+ const first = served.firstDate?.slice(0, 10);
172
+ const earlier = change?.kind === "formerly" && first ? change.changes.filter((hop) => first < hop.date).map((hop) => hop.from) : [];
173
+ return [
174
+ tickerChangeLine(change),
175
+ earlier.length > 0 ? `Includes ${earlier.join("- and ")}-era bars: ${change!.note}` : null,
176
+ ].filter((line): line is string => !!line);
177
+ }
178
+
179
+ /**
180
+ * What a report or loader with nothing of its own to serve for a symbol
181
+ * throws when the symbol's company moved (`ListingFate.moved`): its message
182
+ * is the change's line, "HFC became DINO on 2022-03-15".
183
+ */
184
+ export class MovedSymbolError extends Error {
185
+ constructor(readonly change: MovedTickerChange) {
186
+ super(change.note);
187
+ this.name = "MovedSymbolError";
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The listing an instrument's fate is read for: its symbol without the venue
193
+ * a key carries (HFC:NYSE is HFC on NYSE). Null for a broker's contract,
194
+ * which is not a listing the change history is about.
195
+ */
196
+ export function fateListing(instrument: {
197
+ symbol: string;
198
+ exchange?: string | null;
199
+ brokerId?: string;
200
+ instrument?: unknown;
201
+ }): { symbol: string; exchange: string } | null {
202
+ if (instrument.brokerId || instrument.instrument) return null;
203
+ const key = parsePublicTickerKey(instrument.symbol);
204
+ return { symbol: key.symbol, exchange: key.exchange ?? instrument.exchange ?? "" };
205
+ }
206
+
207
+ /**
208
+ * What a pane does with a symbol: serve it, wait to know, or show that its
209
+ * company moved (`ListingFate.moved`) instead of another security's data.
210
+ * A symbol waits only while what is known says nothing trades under it: a
211
+ * quote that shows the line ended waits for the change lookup, and a change
212
+ * that says the company left with nothing known listed there waits for the
213
+ * quote. Any other symbol is served at once, whatever is still coming, a
214
+ * quote cached from an earlier session included. A lookup that failed or
215
+ * answered nothing is no change.
216
+ */
217
+ export type ListingFateGate =
218
+ | { kind: "serve" }
219
+ | { kind: "wait" }
220
+ | { kind: "moved"; change: MovedTickerChange };
221
+
222
+ const SERVE: ListingFateGate = { kind: "serve" };
223
+ const WAIT: ListingFateGate = { kind: "wait" };
224
+
225
+ /**
226
+ * `ListingFateGate` from what is known so far. `quote` and `changes` are
227
+ * undefined while they load and null when there is none. A move on record
228
+ * is told by the quote, which says whether the old line stopped or something
229
+ * new trades there (FB's fund); until it is in, the change's own word on
230
+ * what lists there now decides whether to wait for it.
231
+ */
232
+ export function listingFateGate(
233
+ listing: { symbol: string; exchange?: string | null },
234
+ { quote, changes }: { quote: Quote | null | undefined; changes: TickerChanges | null | undefined },
235
+ now = Date.now(),
236
+ ): ListingFateGate {
237
+ if (changes === undefined) return quote && endedTradingDate(quote, now) ? WAIT : SERVE;
238
+ if (!changes?.moved) return SERVE;
239
+ if (quote === undefined) return movedAwayChange(describeTickerChange(listing.symbol, changes)) ? WAIT : SERVE;
240
+ const { moved } = describeListingFate(listing, quote, changes, now);
241
+ return moved ? { kind: "moved", change: moved } : SERVE;
242
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AppTickerRepositoryPort } from "../core/app-service-ports";
2
2
  import type { SearchRequestContext, DataProvider } from "../types/data-provider";
3
+ import type { Quote } from "../types/financials";
3
4
  import type { TickerRecord } from "../types/ticker";
4
5
  import {
5
6
  AmbiguousTickerError,
@@ -17,6 +18,15 @@ import { settleTypedListingKey } from "./listing-venues";
17
18
 
18
19
  export type { TickerOpenTarget } from "./search/types";
19
20
 
21
+ /**
22
+ * What an open that found nothing says: neither the key's own quote nor the
23
+ * listing search found the listing. Opens from a board row, a link and the
24
+ * command bar all say it the same way.
25
+ */
26
+ export function tickerOpenFailureMessage(symbol: string): string {
27
+ return `Could not open ${symbol}: no quote or listing found.`;
28
+ }
29
+
20
30
  export async function resolveTickerOpenTarget({
21
31
  query,
22
32
  tickers,
@@ -85,18 +95,22 @@ export async function resolveTickerOpenTarget({
85
95
  return { symbol: ticker.metadata.ticker, ticker, created, ...tickerSelectionFromSearchResult(resolved.result) };
86
96
  }
87
97
 
98
+ let quote: Quote | null = null;
99
+ try {
100
+ quote = await dataProvider.getQuote(symbol, "", publicOnly ? { instrument: null } : undefined);
101
+ } catch {
102
+ // No source has a price for the key right now.
103
+ }
104
+ const quoteExchange = quote ? quote.listingExchangeName ?? quote.exchangeName : undefined;
105
+ // A public open (a board row, a link) skips search for its quote. When the
106
+ // quote cannot vouch for the listing, because no source prices it right now
107
+ // or the one that answers prices another venue, search still lists it as
108
+ // the command bar reads it, and the pane shows the quote's own state.
109
+ if (!quote || (preserveListingKey && !quoteNamesListing(quote))) {
110
+ return publicOnly ? openListedPublicTarget() : null;
111
+ }
112
+
88
113
  try {
89
- const quote = await dataProvider.getQuote(symbol, "", publicOnly ? { instrument: null } : undefined);
90
- const quoteExchange = quote.listingExchangeName ?? quote.exchangeName;
91
- if (preserveListingKey && quoteExchange) {
92
- const baseSymbol = requested.exchange ? requested.symbol : symbol.slice(0, symbol.lastIndexOf("."));
93
- if (!findExactTickerSearchMatch([{ label: baseSymbol, right: quoteExchange }], symbol)) return null;
94
- }
95
- if (preserveListingKey) {
96
- const listingAlias = requested.exchange ? getListingSymbol(requested.symbol, requested.exchange) : symbol;
97
- const explicitAliasMatches = tickerHasListingSuffix(listingAlias) && normalizeTickerSymbol(quote.symbol) === listingAlias;
98
- if (!explicitAliasMatches && !findExactTickerSearchMatch([{ label: quote.symbol, right: quoteExchange }], symbol)) return null;
99
- }
100
114
  const quoteSymbol = preserveListingKey ? symbol : normalizeTickerSymbol(quote.symbol || symbol);
101
115
  const selection = publicOnly ? tickerSelectionFromSearchResult({ providerId: dataProvider.id, symbol: quoteSymbol,
102
116
  name: quote.name || quoteSymbol, exchange: requested.exchange ?? quoteExchange ?? "", currency: quote.currency, type: quote.instrumentType || "" }) : {};
@@ -122,4 +136,40 @@ export async function resolveTickerOpenTarget({
122
136
  } catch {
123
137
  return null;
124
138
  }
139
+
140
+ /** Whether the quote answered for the listing the key names, not the same symbol on another venue. */
141
+ function quoteNamesListing(answer: Quote): boolean {
142
+ try {
143
+ if (quoteExchange) {
144
+ const baseSymbol = requested.exchange ? requested.symbol : symbol.slice(0, symbol.lastIndexOf("."));
145
+ if (!findExactTickerSearchMatch([{ label: baseSymbol, right: quoteExchange }], symbol)) return false;
146
+ }
147
+ const listingAlias = requested.exchange ? getListingSymbol(requested.symbol, requested.exchange) : symbol;
148
+ const explicitAliasMatches = tickerHasListingSuffix(listingAlias) && normalizeTickerSymbol(answer.symbol) === listingAlias;
149
+ return explicitAliasMatches || !!findExactTickerSearchMatch([{ label: answer.symbol, right: quoteExchange }], symbol);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ async function openListedPublicTarget(): Promise<TickerOpenTarget | null> {
156
+ try {
157
+ // Public listings only: no saved ticker or broker contract stands in for the key.
158
+ const listed = await resolveTickerSearch({
159
+ query: symbol,
160
+ activeTicker: null,
161
+ tickers: new Map(),
162
+ dataProvider,
163
+ searchContext: { ...searchContext, preferBroker: false },
164
+ });
165
+ if (listed?.kind !== "provider" || listed.result.brokerContract) return null;
166
+ const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, listed.result, {
167
+ tickerSymbol: preserveListingKey ? symbol : undefined,
168
+ });
169
+ return { symbol: ticker.metadata.ticker, ticker, created, ...tickerSelectionFromSearchResult(listed.result) };
170
+ } catch (error) {
171
+ if (error instanceof AmbiguousTickerError) throw error;
172
+ return null;
173
+ }
174
+ }
125
175
  }
@@ -2,7 +2,7 @@ import type { SearchRequestContext, DataProvider } from "../../types/data-provid
2
2
  import type { InstrumentSearchResult } from "../../types/instrument";
3
3
  import type { TickerRecord } from "../../types/ticker";
4
4
  import { canonicalExchange, isSharedExchangeCode, isUsListingExchange, parsePublicTickerKey, publicTickerKey, typedExchangeCode } from "../../utils/exchanges";
5
- import { getListingSymbol, tickerHasListingSuffix } from "../../sources/listing-symbols";
5
+ import { getListingSymbol, listingSuffixConflictsWithExchange, tickerHasListingSuffix } from "../../sources/listing-symbols";
6
6
  import { parseOptionSymbol } from "../../utils/options";
7
7
  import { resolveCurrencyUnit } from "../../utils/currency-units";
8
8
  import { currencyPairSpelling } from "../../utils/currency-pair";
@@ -33,6 +33,7 @@ import {
33
33
  } from "./result";
34
34
 
35
35
  import { typedPerpetualListing } from "../perpetual-listing";
36
+ import { settleWithin } from "../../utils/async-deadline";
36
37
  export type {
37
38
  ResolvedTickerSearch,
38
39
  TickerSearchCandidate,
@@ -319,18 +320,59 @@ export function resolveTickerSearch(options: ResolveTickerSearchOptions): Promis
319
320
  const symbol = future?.symbol ?? (pair && !pair.ambiguous ? pair.symbol : typed);
320
321
  const literal = resolveTickerSymbol(symbol, options, pickListing ? requested : null);
321
322
  const alternatives = pickListing ? [] : [...dottedVenueQueries(symbol), ...pair?.ambiguous ? [pair.symbol] : []];
323
+ // A listing suffix search does not carry (CEZ.PR) is read from its own quote, as `quote` reads it.
324
+ const quoted = !pickListing && tickerHasListingSuffix(symbol) && !parsePublicTickerKey(symbol).exchange;
322
325
  // Most input has no venue code to retry: hand back the literal lookup
323
326
  // itself, so it settles no later than it did before the retry existed.
324
- if (alternatives.length === 0) return literal;
327
+ if (alternatives.length === 0 && !quoted) return literal;
325
328
  return literal.then(async (resolved) => {
326
329
  for (const alternative of resolved ? [] : alternatives) {
327
330
  const match = await resolveTickerSymbol(alternative, options, null);
328
331
  if (match) return match;
329
332
  }
330
- return resolved;
333
+ return resolved ?? (quoted ? quotedSuffixListing(symbol, options) : null);
331
334
  });
332
335
  }
333
336
 
337
+ /** As long as a search source gets: past it, the key reads as unmatched. */
338
+ const SUFFIX_QUOTE_TIMEOUT_MS = 5_000;
339
+
340
+ /**
341
+ * A suffixed listing (CEZ.PR, PETR4.SA, VOLV-B.ST) that search does not list,
342
+ * vouched for by its own quote: the quote answers for that key, prices it, and
343
+ * names a venue the suffix belongs to. This is how `quote` and opening the
344
+ * key from the command bar read it, so adding it to a watchlist, a portfolio
345
+ * or the first-run positions takes the same listing.
346
+ */
347
+ async function quotedSuffixListing(
348
+ symbol: string,
349
+ { dataProvider, searchContext }: ResolveTickerSearchOptions,
350
+ ): Promise<ResolvedTickerSearch | null> {
351
+ // No source prices the key, or none answers in time: it stays unmatched.
352
+ const quote = await settleWithin(dataProvider.getQuote(symbol, "", {
353
+ brokerId: searchContext?.brokerId,
354
+ brokerInstanceId: searchContext?.brokerInstanceId,
355
+ }), SUFFIX_QUOTE_TIMEOUT_MS);
356
+ if (!quote) return null;
357
+ const exchange = canonicalExchange(quote.listingExchangeName || quote.exchangeName);
358
+ if (!exchange || normalizeTickerSymbol(quote.symbol || "") !== symbol
359
+ || !Number.isFinite(quote.price) || quote.price === 0
360
+ || listingSuffixConflictsWithExchange(symbol, exchange)) return null;
361
+ return {
362
+ kind: "provider",
363
+ symbol,
364
+ result: {
365
+ providerId: dataProvider.id,
366
+ symbol,
367
+ name: quote.name?.trim() || symbol,
368
+ exchange,
369
+ currency: quote.currency || undefined,
370
+ type: quote.instrumentType || "",
371
+ },
372
+ quote,
373
+ };
374
+ }
375
+
334
376
  async function resolveTickerSymbol(
335
377
  symbol: string,
336
378
  { tickers, dataProvider, searchContext }: ResolveTickerSearchOptions,
@@ -252,6 +252,7 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
252
252
  : null,
253
253
  explicitIntentScore,
254
254
  themeFund: themeWord != null && isThemeFund(item, themeWord) ? 1 : 0,
255
+ themeCompany: themeWord != null && isThemeCompany(item, themeWord) ? 1 : 0,
255
256
  normalizedSymbol: normalizeSearchText(item.symbol || item.label),
256
257
  symbolMatchRank: scoreSymbolMatchRank(intent, item),
257
258
  nameOnly: companyMatchRank > 0 && labelScore + aliasScore <= 0
@@ -270,6 +271,14 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
270
271
  if (!namedCompany && leadingFunds.size < LEADING_THEME_FUNDS) leadingFunds.add(symbol);
271
272
  if (!leadingFunds.has(symbol)) entry.themeFund = 0;
272
273
  }
274
+ // The companies the server sent with the theme (Albemarle, SQM and PLS for "lithium") follow those funds, in its
275
+ // order, ahead of the smaller companies whose names carry the word, so the leaders are on the first screen.
276
+ const leadingCompanies = new Set<string>();
277
+ for (const entry of ranked.filter(({ themeCompany }) => themeCompany).sort(byProviderRank)) {
278
+ const key = getTickerSearchListingKey(entry.item);
279
+ if (!namedCompany && leadingCompanies.size < LEADING_THEME_COMPANIES) leadingCompanies.add(key);
280
+ if (!leadingCompanies.has(key)) entry.themeCompany = 0;
281
+ }
273
282
 
274
283
  // A saved symbol replaces its own source listing, not every exchange using it.
275
284
  const matchedLocalListings = new Set(
@@ -374,7 +383,7 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
374
383
 
375
384
  filtered.sort((a, b) => {
376
385
  // Precedence is exact symbol, explicit query hints, a sector word's funds
377
- // in the server's order, whole-word company relevance, issuer relevance, provider order within that issuer, partial
386
+ // and then the companies sent with it, each in the server's order, whole-word company relevance, issuer relevance, provider order within that issuer, partial
378
387
  // symbol, saved relevance, then stable source order.
379
388
  const aExactSymbolRank = a.symbolMatchRank >= 2 ? a.symbolMatchRank : 0;
380
389
  const bExactSymbolRank = b.symbolMatchRank >= 2 ? b.symbolMatchRank : 0;
@@ -383,7 +392,8 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
383
392
  return b.explicitIntentScore - a.explicitIntentScore;
384
393
  }
385
394
  if (b.themeFund !== a.themeFund) return b.themeFund - a.themeFund;
386
- if (a.themeFund) {
395
+ if (b.themeCompany !== a.themeCompany) return b.themeCompany - a.themeCompany;
396
+ if (a.themeFund || a.themeCompany) {
387
397
  const aProviderRank = a.item.providerRank ?? Number.POSITIVE_INFINITY;
388
398
  const bProviderRank = b.item.providerRank ?? Number.POSITIVE_INFINITY;
389
399
  if (aProviderRank !== bProviderRank) return aProviderRank - bProviderRank;
@@ -589,6 +599,13 @@ export function themeQueryWord(
589
599
 
590
600
  /** How many sector funds lead a sector word's list: the rest of the list is the companies named for it. */
591
601
  const LEADING_THEME_FUNDS = 5;
602
+ /** How many companies sent with a sector word follow its funds, before the companies named for it. */
603
+ const LEADING_THEME_COMPANIES = 5;
604
+
605
+ /** A company the server sent with the theme: a sector's leader whose name need not say it (Albemarle for "lithium"). */
606
+ function isThemeCompany(item: Partial<TickerSearchRankableItem>, word: string): boolean {
607
+ return item.instrumentClass === "equity" && !!item.searchKeywords?.some((keyword) => normalizeSearchText(keyword) === word);
608
+ }
592
609
 
593
610
  /**
594
611
  * A fund that tracks the theme (sent with it, or named for it) on a US exchange, where the large sector funds
@@ -1,4 +1,5 @@
1
1
  import type { BrokerContractRef, InstrumentSearchResult, TickerListingRef } from "../../types/instrument";
2
+ import type { Quote } from "../../types/financials";
2
3
  import type { TickerRecord } from "../../types/ticker";
3
4
 
4
5
  export type TickerSearchInstrumentClass = "equity" | "fund" | "derivative" | "other";
@@ -44,6 +45,8 @@ export type ResolvedTickerSearch =
44
45
  result: InstrumentSearchResult;
45
46
  /** A bare symbol's other listings (SYM:EXCH) when its quote chose one of several. */
46
47
  otherListings?: string[];
48
+ /** The quote that vouched for a listing search does not carry, so the caller need not ask again. */
49
+ quote?: Quote;
47
50
  };
48
51
 
49
52
  export interface TickerOpenTarget {
@@ -11,6 +11,25 @@ import {
11
11
  import { canonicalExchange, parsePublicTickerKey, publicTickerKey } from "../../utils/exchanges";
12
12
  import { scopedBrokerContractIdentityKey } from "../../utils/instrument-identity";
13
13
 
14
+ /**
15
+ * The key a result is saved under when its symbol is already saved for
16
+ * another listing (`SAP:XETR` beside a saved SAP on NYSE), or null when the
17
+ * symbol's own key serves. Opening a second listing must never re-denominate
18
+ * existing positions.
19
+ */
20
+ export function otherListingTickerKey(
21
+ result: InstrumentSearchResult,
22
+ symbol: string,
23
+ saved: TickerRecord | null | undefined,
24
+ ): string | null {
25
+ const selectedExchange = canonicalExchange(
26
+ result.exchange === "SMART" ? result.primaryExchange : result.exchange || result.primaryExchange,
27
+ );
28
+ const savedExchange = canonicalExchange(saved?.metadata.exchange);
29
+ if (!saved || !selectedExchange || !savedExchange || selectedExchange === savedExchange) return null;
30
+ return publicTickerKey(parsePublicTickerKey(symbol).symbol, selectedExchange);
31
+ }
32
+
14
33
  export async function upsertTickerFromSearchResult(
15
34
  tickerRepository: AppTickerRepositoryPort,
16
35
  result: InstrumentSearchResult,
@@ -18,13 +37,9 @@ export async function upsertTickerFromSearchResult(
18
37
  ): Promise<{ ticker: TickerRecord; created: boolean }> {
19
38
  let symbol = options.tickerSymbol ?? getSearchResultSymbol(result);
20
39
  let ticker = await tickerRepository.loadTicker(symbol);
21
- const selectedExchange = canonicalExchange(
22
- result.exchange === "SMART" ? result.primaryExchange : result.exchange || result.primaryExchange,
23
- );
24
- const savedExchange = canonicalExchange(ticker?.metadata.exchange);
25
- if (ticker && selectedExchange && savedExchange && selectedExchange !== savedExchange) {
26
- // Opening a second listing must never re-denominate existing positions.
27
- symbol = publicTickerKey(parsePublicTickerKey(symbol).symbol, selectedExchange);
40
+ const otherListingKey = otherListingTickerKey(result, symbol, ticker);
41
+ if (otherListingKey) {
42
+ symbol = otherListingKey;
28
43
  ticker = await tickerRepository.loadTicker(symbol);
29
44
  }
30
45
  const created = !ticker;
@@ -39,8 +39,13 @@ type TickerChangeSource = Pick<DataProvider, "getTickerChanges">;
39
39
  /** A lookup in flight: `settle` waits a little more for it, then gives up and stops the request. */
40
40
  export interface TickerChangeLookup {
41
41
  settle(graceMs?: number): Promise<TickerChanges | null>;
42
+ /** Its answer once it has one, null when it failed or there is nothing to look up; undefined while it runs. */
43
+ peek(): TickerChanges | null | undefined;
42
44
  }
43
45
 
46
+ /** A change whose company moved to another symbol (`movedAwayChange`). */
47
+ export type MovedTickerChange = TickerChange & { tradesAs: string };
48
+
44
49
  /** How long a command waits for a note once its own answer is ready. */
45
50
  const TICKER_CHANGE_GRACE_MS = 200;
46
51
  /** The longest a lookup runs, for a caller that keeps waiting. */
@@ -54,12 +59,13 @@ interface Entry {
54
59
  promise: Promise<TickerChanges | null>;
55
60
  controller: AbortController;
56
61
  done: boolean;
62
+ value: TickerChanges | null;
57
63
  failedAt: number | null;
58
64
  }
59
65
 
60
66
  /** One session's answers per source: a symbol is looked up once. */
61
67
  const sessions = new WeakMap<TickerChangeSource, Map<string, Entry>>();
62
- const NONE: TickerChangeLookup = { settle: async () => null };
68
+ const NONE: TickerChangeLookup = { settle: async () => null, peek: () => null };
63
69
 
64
70
  /** A venue the change history is about: none named, a US exchange, or US over-the-counter. */
65
71
  export function isUsSymbolVenue(exchange?: string | null): boolean {
@@ -99,9 +105,9 @@ function entryFor(symbol: string, source: TickerChangeSource & Required<TickerCh
99
105
  if (cached && (cached.failedAt === null || Date.now() - cached.failedAt < RETRY_FAILED_AFTER_MS)) return cached;
100
106
  const controller = new AbortController();
101
107
  const timer = setTimeout(() => controller.abort(), LOOKUP_TIMEOUT_MS);
102
- const entry: Entry = { promise: Promise.resolve(null), controller, done: false, failedAt: null };
108
+ const entry: Entry = { promise: Promise.resolve(null), controller, done: false, value: null, failedAt: null };
103
109
  entry.promise = source.getTickerChanges(symbol, { signal: controller.signal })
104
- .then(readPayload)
110
+ .then((payload) => entry.value = readPayload(payload))
105
111
  .catch(() => {
106
112
  entry.failedAt = Date.now();
107
113
  return null;
@@ -140,6 +146,7 @@ export function startTickerChangeLookup(
140
146
  entry.controller.abort();
141
147
  return null;
142
148
  },
149
+ peek: () => entry.done ? entry.value : undefined,
143
150
  };
144
151
  }
145
152
 
@@ -153,9 +160,9 @@ function day(date: string): number {
153
160
  return Date.parse(`${date}T00:00:00Z`);
154
161
  }
155
162
 
156
- /** "to META on 2022-06-09, then MMATQ on 2024-08-21": each later hop of a chain. */
157
- function laterHops(hops: readonly TickerChangeHop[], lead: string): string {
158
- return hops.slice(1).map((hop) => `, then ${lead}${hop.to} on ${hop.date}`).join("");
163
+ /** ", then MMATQ on 2024-08-21": each later hop of a chain. */
164
+ function laterHops(hops: readonly TickerChangeHop[]): string {
165
+ return hops.slice(1).map((hop) => `, then ${hop.to} on ${hop.date}`).join("");
159
166
  }
160
167
 
161
168
  /**
@@ -167,6 +174,16 @@ export function tickerChangeLine(change: TickerChange | null | undefined): strin
167
174
  return change?.kind === "moved" ? change.note : null;
168
175
  }
169
176
 
177
+ /**
178
+ * The change when the company that held the symbol trades under another now
179
+ * and nothing new lists under it (HFC became DINO): the symbol has no data of
180
+ * its own. Null for a symbol a new security took (FB lists a fund), one its
181
+ * holder renamed into (META), and one with no change.
182
+ */
183
+ export function movedAwayChange(change: TickerChange | null | undefined): MovedTickerChange | null {
184
+ return change?.kind === "moved" && !change.listed && change.tradesAs ? { ...change, tradesAs: change.tradesAs } : null;
185
+ }
186
+
170
187
  /**
171
188
  * The note for a symbol's changes. `current` is what the command found
172
189
  * trading under the symbol: null when nothing does, left out when it did not
@@ -174,7 +191,7 @@ export function tickerChangeLine(change: TickerChange | null | undefined): strin
174
191
  * left, is that issuer's old line rather than a new listing.
175
192
  *
176
193
  * "FB now lists ProShares S&P 500 Dynamic Daily Buffer ETF; Meta Platforms trades as META (changed 2022-06-09)"
177
- * "FB changed to META on 2022-06-09"
194
+ * "HFC became DINO on 2022-03-15"
178
195
  * "METV was META until 2022-01-31" (data only: `tickerChangeLine` prints none)
179
196
  */
180
197
  export function describeTickerChange(
@@ -197,10 +214,10 @@ export function describeTickerChange(
197
214
  const listed = listedHere ? current?.name?.trim() || payload.listedName || "another security" : null;
198
215
  const when = moved.hops.length === 1
199
216
  ? `changed ${first.date}`
200
- : `changed to ${first.to} on ${first.date}${laterHops(moved.hops, "")}`;
217
+ : `changed to ${first.to} on ${first.date}${laterHops(moved.hops)}`;
201
218
  const note = listed
202
219
  ? `${symbol} now lists ${listed}; ${issuer ?? `the former ${symbol}`} trades as ${moved.symbol} (${when})`
203
- : `${symbol} changed to ${first.to} on ${first.date}${laterHops(moved.hops, "to ")}`;
220
+ : `${symbol} became ${first.to} on ${first.date}${laterHops(moved.hops)}`;
204
221
  return { symbol, kind: "moved", note, changes: moved.hops, tradesAs: moved.symbol, issuer, listed };
205
222
  }
206
223
  if (formerly) {
@@ -0,0 +1,19 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1000;
2
+
3
+ /**
4
+ * The typical spacing of a series of bar times: the median gap between
5
+ * consecutive distinct times, so the long gap a holiday leaves (Golden Week
6
+ * closes Shanghai from 30 Sep to 8 Oct) does not read as the bar size. Null
7
+ * with fewer than two distinct times.
8
+ */
9
+ export function medianBarGap(times: readonly number[]): number | null {
10
+ const finite = times.filter(Number.isFinite).sort((left, right) => left - right);
11
+ const gaps = finite.slice(1).map((time, index) => time - finite[index]!).filter((gap) => gap > 0).sort((left, right) => left - right);
12
+ return gaps.length > 0 ? gaps[Math.floor((gaps.length - 1) / 2)]! : null;
13
+ }
14
+
15
+ /** A bar size of a day or more read from the typical spacing of its bars (medianBarGap). */
16
+ export function barSizeFromGap(gapMs: number): "1d" | "1wk" | "1mo" {
17
+ const days = gapMs / DAY_MS;
18
+ return days >= 25 ? "1mo" : days >= 6 ? "1wk" : "1d";
19
+ }