gloomberb 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/errors.ts +38 -0
  10. package/src/api-client/hiring.ts +3 -0
  11. package/src/api-client/index.ts +4 -0
  12. package/src/api-client/ipo.ts +24 -1
  13. package/src/api-client/mna.ts +3 -0
  14. package/src/api-client/paths.ts +9 -1
  15. package/src/api-client/roadmap.ts +85 -0
  16. package/src/api-client/types.ts +12 -0
  17. package/src/app/runtime/startup.ts +3 -1
  18. package/src/architecture/core-builtin-allowlist.json +2 -2
  19. package/src/capabilities/chart-series.ts +2 -1
  20. package/src/cli/commands/market.ts +44 -16
  21. package/src/cli/commands/news-search.ts +22 -0
  22. package/src/cli/commands/overview.ts +84 -19
  23. package/src/cli/commands/search.ts +13 -13
  24. package/src/cli/commands/ticker.ts +32 -2
  25. package/src/cli/context.ts +1 -0
  26. package/src/cli/errors.ts +17 -2
  27. package/src/cli/glossary.ts +8 -1
  28. package/src/cli/history-rows.ts +35 -7
  29. package/src/cli/index-roots.ts +0 -7
  30. package/src/cli/known-gaps.ts +11 -0
  31. package/src/cli/listing-arg.ts +74 -11
  32. package/src/cli/pane-functions/capabilities.ts +1 -0
  33. package/src/cli/pane-functions/discovery.ts +21 -1
  34. package/src/cli/pane-functions/freshness.ts +7 -1
  35. package/src/cli/pane-functions/function-help.ts +7 -0
  36. package/src/cli/pane-functions/index.ts +6 -3
  37. package/src/cli/quote-answer.ts +30 -7
  38. package/src/cli/saved-copy-persistence.ts +88 -0
  39. package/src/components/command-bar/list/model.ts +5 -0
  40. package/src/components/command-bar/routes/root/runtime.ts +29 -8
  41. package/src/components/command-bar/selection-runtime.ts +64 -32
  42. package/src/components/command-bar/surface/index.tsx +2 -0
  43. package/src/components/layout/detached-pane-shell.tsx +2 -2
  44. package/src/components/layout/header.tsx +2 -2
  45. package/src/components/layout/pane/capture-stamp.ts +2 -2
  46. package/src/components/layout/pane/footer/index.tsx +23 -6
  47. package/src/components/layout/pane/footer/model.ts +116 -16
  48. package/src/components/layout/pane/header.tsx +2 -2
  49. package/src/components/layout/pane/status-footer.ts +37 -3
  50. package/src/components/layout/status-widget-space.ts +9 -0
  51. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  52. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  53. package/src/components/ticker/list-table-view.tsx +4 -20
  54. package/src/components/ui/display.tsx +29 -3
  55. package/src/components/ui/icon.tsx +12 -3
  56. package/src/core/app-services.ts +11 -2
  57. package/src/data/app-persistence.ts +9 -7
  58. package/src/data/plugin-cache.ts +20 -3
  59. package/src/data/plugin-state-store.ts +81 -24
  60. package/src/data/resource-store.ts +130 -58
  61. package/src/data/saved-copies.ts +8 -0
  62. package/src/data/session-store.ts +31 -8
  63. package/src/data/sqlite/access.ts +313 -0
  64. package/src/data/sqlite/checkpointer.ts +198 -0
  65. package/src/data/sqlite/database.ts +47 -4
  66. package/src/data/sqlite/retry.ts +50 -9
  67. package/src/data/ticker-store.ts +57 -23
  68. package/src/docs/snapshot.json +32 -5
  69. package/src/i18n/es.ts +2 -4
  70. package/src/i18n/ja.ts +2 -4
  71. package/src/i18n/ko.ts +2 -4
  72. package/src/i18n/zh-cn.ts +2 -4
  73. package/src/i18n/zh-tw.ts +2 -4
  74. package/src/market-data/depositary-pair.ts +25 -1
  75. package/src/market-data/quotes/metadata.ts +7 -0
  76. package/src/market-data/realized-volatility.ts +4 -2
  77. package/src/news/aggregator.ts +7 -1
  78. package/src/news/types.ts +6 -0
  79. package/src/plugins/auto-update-node.ts +10 -6
  80. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  81. package/src/plugins/builtin/alerts/cli.ts +34 -11
  82. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  83. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  84. package/src/plugins/builtin/alerts/headless.ts +10 -6
  85. package/src/plugins/builtin/cds/client.ts +142 -30
  86. package/src/plugins/builtin/cds/headless.ts +35 -8
  87. package/src/plugins/builtin/cds/model.ts +145 -18
  88. package/src/plugins/builtin/cds/pane.tsx +16 -5
  89. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  90. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  91. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  92. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  93. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  94. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  95. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  96. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  97. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  98. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  99. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  100. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  101. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  102. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  103. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  104. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  105. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  106. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  107. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  108. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  109. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  110. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  111. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  112. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  113. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  114. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  115. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  116. package/src/plugins/builtin/composite-plugins.ts +2 -1
  117. package/src/plugins/builtin/cot/client.ts +28 -2
  118. package/src/plugins/builtin/cot/headless.ts +4 -1
  119. package/src/plugins/builtin/cot/index.tsx +4 -2
  120. package/src/plugins/builtin/cot/model.ts +58 -7
  121. package/src/plugins/builtin/cot/pane.tsx +6 -2
  122. package/src/plugins/builtin/cpi/client.ts +6 -5
  123. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  124. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  125. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  126. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  127. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  128. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  129. package/src/plugins/builtin/doe/client.ts +6 -5
  130. package/src/plugins/builtin/doe/headless.ts +3 -2
  131. package/src/plugins/builtin/doe/pane.tsx +13 -3
  132. package/src/plugins/builtin/doe/prices.ts +2 -3
  133. package/src/plugins/builtin/doe/series.ts +4 -4
  134. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  135. package/src/plugins/builtin/earnings-calls/data.ts +37 -17
  136. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  137. package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
  138. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  139. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  140. package/src/plugins/builtin/econ/detail-view.tsx +18 -4
  141. package/src/plugins/builtin/econ/figures.ts +48 -0
  142. package/src/plugins/builtin/econ/index.tsx +14 -5
  143. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  144. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  145. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  146. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  147. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  148. package/src/plugins/builtin/executives/data.ts +6 -1
  149. package/src/plugins/builtin/executives/pane.tsx +9 -21
  150. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  151. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  152. package/src/plugins/builtin/gpu/client.ts +4 -1
  153. package/src/plugins/builtin/gpu/series.ts +2 -3
  154. package/src/plugins/builtin/help/index.tsx +61 -59
  155. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  156. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  157. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  158. package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
  159. package/src/plugins/builtin/holders/headless.ts +4 -2
  160. package/src/plugins/builtin/holders/pane.tsx +3 -0
  161. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  162. package/src/plugins/builtin/insider/headless.ts +2 -2
  163. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  164. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  165. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  166. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  167. package/src/plugins/builtin/jodi/client.ts +4 -1
  168. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  169. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  170. package/src/plugins/builtin/macro-day/model.ts +39 -5
  171. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  172. package/src/plugins/builtin/market-movers/client.ts +3 -0
  173. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  174. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  175. package/src/plugins/builtin/market-movers/screener.ts +27 -6
  176. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  177. package/src/plugins/builtin/mna/headless.ts +2 -0
  178. package/src/plugins/builtin/mna/pane.tsx +3 -1
  179. package/src/plugins/builtin/money-markets/series.ts +3 -5
  180. package/src/plugins/builtin/news/headless.ts +3 -1
  181. package/src/plugins/builtin/news/index.tsx +3 -0
  182. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  183. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  184. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  185. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  186. package/src/plugins/builtin/power/gas-client.ts +2 -1
  187. package/src/plugins/builtin/power/prices-client.ts +3 -2
  188. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  189. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  190. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  191. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  192. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  193. package/src/plugins/builtin/research/event-model.ts +30 -4
  194. package/src/plugins/builtin/research/index.tsx +7 -3
  195. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  196. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  197. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  198. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  199. package/src/plugins/builtin/risk-factors/data.ts +12 -6
  200. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  201. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  202. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  203. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  204. package/src/plugins/builtin/roadmap/model.ts +67 -0
  205. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  206. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  207. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  208. package/src/plugins/builtin/sec/headless.ts +2 -1
  209. package/src/plugins/builtin/sec/index.tsx +3 -0
  210. package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
  211. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  212. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  213. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  214. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  215. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  216. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  217. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  218. package/src/plugins/builtin/short-volume/series.ts +2 -3
  219. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  220. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  221. package/src/plugins/builtin/thirteenf/api.ts +15 -9
  222. package/src/plugins/builtin/thirteenf/data.ts +39 -11
  223. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  224. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  225. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  226. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  227. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  228. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  229. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  230. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  231. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  232. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  233. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  234. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  235. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  236. package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
  237. package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
  238. package/src/plugins/builtin/volatility/client.ts +43 -16
  239. package/src/plugins/builtin/volatility/headless.ts +5 -2
  240. package/src/plugins/builtin/volatility/model.ts +17 -6
  241. package/src/plugins/builtin/volatility/pane.tsx +11 -4
  242. package/src/plugins/builtin/volatility/series.ts +2 -1
  243. package/src/plugins/builtin/yield-curve/client.ts +59 -1
  244. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  245. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  246. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  247. package/src/plugins/catalog-browser.ts +2 -1
  248. package/src/plugins/host-link.ts +1 -1
  249. package/src/plugins/installer.ts +50 -39
  250. package/src/portfolio/column-values.ts +11 -7
  251. package/src/renderers/dom/desktop/icons.tsx +10 -2
  252. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  253. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  254. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  255. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  256. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  257. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  258. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  259. package/src/renderers/electrobun/bun/index.ts +26 -2
  260. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  261. package/src/renderers/electrobun/bun/window/create.ts +45 -4
  262. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  263. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  264. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  265. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  266. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  267. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  268. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  269. package/src/renderers/electrobun/view/news-source.ts +2 -0
  270. package/src/renderers/electrobun/view/ui-host.tsx +16 -1
  271. package/src/series/resolve.ts +2 -1
  272. package/src/series/types.ts +5 -0
  273. package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
  274. package/src/sources/gloomberb-cloud/index.ts +42 -8
  275. package/src/telemetry/stall-reports-node.ts +13 -2
  276. package/src/telemetry/stall-reports.ts +189 -13
  277. package/src/test-support/outage.ts +8 -0
  278. package/src/tickers/company-name-listing.ts +110 -40
  279. package/src/tickers/company-name.ts +71 -2
  280. package/src/tickers/index-roots.ts +7 -0
  281. package/src/tickers/listing-fate.ts +2 -2
  282. package/src/tickers/search/home-listing.ts +93 -8
  283. package/src/tickers/search/index.ts +7 -3
  284. package/src/tickers/search/ranking.ts +5 -1
  285. package/src/tickers/search/upsert.ts +13 -0
  286. package/src/tickers/ticker-changes.ts +8 -4
  287. package/src/time-series/market.ts +5 -3
  288. package/src/time-series/price-comparison.ts +20 -2
  289. package/src/time-series/resolve.ts +20 -1
  290. package/src/time-series/types.ts +7 -0
  291. package/src/types/company-from.ts +40 -0
  292. package/src/types/data-provider.ts +7 -0
  293. package/src/types/financials.ts +45 -3
  294. package/src/types/headless.ts +6 -0
  295. package/src/ui/host.tsx +6 -1
  296. package/src/utils/crypto-pair.ts +121 -7
  297. package/src/utils/declared-dividends.ts +1 -1
  298. package/src/utils/financial-statements.ts +73 -16
  299. package/src/utils/fundamentals.ts +53 -10
  300. package/src/utils/lease-liabilities.ts +54 -0
  301. package/src/utils/main-thread-stall.ts +47 -2
  302. package/src/utils/perf-marks.ts +23 -11
  303. package/src/utils/price-history-integrity.ts +47 -1
  304. package/src/version.ts +2 -2
@@ -1,10 +1,11 @@
1
1
  import { logFailure } from "../utils/log-failure";
2
2
  import type { DataProvider } from "../types/data-provider";
3
+ import type { Quote } from "../types/financials";
3
4
  import type { InstrumentSearchResult } from "../types/instrument";
4
5
  import { canonicalExchange, isUsListingExchange, parsePublicTickerKey } from "../utils/exchanges";
5
- import { companyNameMatch, sameCompany, shortCompanyName } from "./company-name";
6
+ import { companyLines, companyNameExtraWords, companyNameMatch, namedByService, runTogether, shortCompanyName } from "./company-name";
6
7
  import { LISTING_SEARCH_LIMIT } from "./search";
7
- import { lineDistance } from "./search/home-listing";
8
+ import { lineDistance, primaryCompanyLine } from "./search/home-listing";
8
9
  import { classifyInstrumentKind, isCryptoInstrumentType } from "./search/ranking";
9
10
  import { isProviderBusy, type ProviderBusyError } from "../sources/provider-errors";
10
11
  import { readTerminalListingCode, type TerminalListing, type TerminalListingCode } from "./terminal-codes";
@@ -12,18 +13,36 @@ import { hasNonLatinLetters } from "../utils/typed-text";
12
13
 
13
14
  export interface CompanyNameListing {
14
15
  symbol: string;
15
- /** Canonical code of its exchange. */
16
+ /** Canonical code of its exchange; empty for a US symbol read bare (a line its company `moved` to). */
16
17
  exchange: string;
17
18
  /** The listing's name as search gives it. */
18
19
  name: string;
20
+ /** The service marked the listing with the name as typed (`searchAliases`): the company it means by it. */
21
+ named?: true;
19
22
  /**
20
23
  * When the listing is a fund or an index carrying the name ("JP Morgan" read
21
24
  * as ^JTEK, JP MORGAN ETF TRUS), the company's common stock the name says
22
25
  * run together (JPM, JPMorgan Chase & Co.), which the reader is asked about.
23
26
  */
24
27
  meant?: Omit<CompanyNameListing, "meant">;
28
+ /**
29
+ * When the company's US line, which search still lists, moved to another
30
+ * symbol and this is the line it trades as now (GETY became GETYQ): the
31
+ * change in one line, "GETY became GETYQ on 2026-10-22".
32
+ */
33
+ moved?: string;
25
34
  }
26
35
 
36
+ /**
37
+ * Where a listing's company trades now when it moved off the symbol (GETY
38
+ * became GETYQ on 2026-10-22), from the line's quote (null when it has none)
39
+ * and the name search gives it: the symbol it took, and the change in one
40
+ * line. Null when it did not move, or that cannot be told.
41
+ */
42
+ export type MovedLineLookup = (
43
+ listing: { symbol: string; exchange: string; quote: Quote | null; name: string | null },
44
+ ) => Promise<{ symbol: string; note: string } | null>;
45
+
27
46
  /** Share lines that are not the company's common stock, though search may type them as such. */
28
47
  const NOT_COMMON = /preferred|warrant|right|unit|note|bond|debenture/i;
29
48
  const RECEIPT = /depositary|^(?:ADR|ADS|GDR|CDR|BDR)$/i;
@@ -50,7 +69,10 @@ export function isCompanyNameInput(input: string): boolean {
50
69
  * depositary receipt (SAP's NYSE line for "SAP SE"), so a receipt is the
51
70
  * listing only of a company listed by receipts alone (PDD); then the row the
52
71
  * service marks with the name, a US line, the shortest symbol (WHR before
53
- * WHR-A) and search's order. The row the service marks with the name as typed
72
+ * WHR-A) and search's order. The first then gives way to the line `search`
73
+ * calls the company's primary listing (`primaryCompanyLine`), though its name
74
+ * says more: "lufthansa" is LHA on XETRA, not LUFTHANSA on Budapest, a
75
+ * cross-listing with no search score. The row the service marks with the name as typed
54
76
  * (`searchAliases`: JPM for "JP Morgan", SIE for "SIEMENS AG", though its name
55
77
  * is Siemens Aktiengesellschaft) is named by it exactly. Contracts, preferred
56
78
  * shares and notes are never the listing. With `quoted`, a listing with no
@@ -70,7 +92,7 @@ export function isCompanyNameInput(input: string): boolean {
70
92
  export async function findListingOfCompanyName(
71
93
  dataProvider: Pick<DataProvider, "search"> & Partial<Pick<DataProvider, "getQuote">>,
72
94
  typed: string,
73
- options: { meant?: boolean; oneWord?: boolean; quoted?: boolean } = {},
95
+ options: { meant?: boolean; oneWord?: boolean; quoted?: boolean; movedLine?: MovedLineLookup } = {},
74
96
  ): Promise<CompanyNameListing | null> {
75
97
  const name = typed.trim();
76
98
  if (!name) return null;
@@ -78,40 +100,62 @@ export async function findListingOfCompanyName(
78
100
  const answers = await Promise.all(queries.map((query) => searchListings(dataProvider, query)));
79
101
  const busy = answers.find(isProviderBusy);
80
102
  const results = answers.flatMap((answer) => Array.isArray(answer) ? answer : []);
81
- const ranked = results.flatMap((result, order) => {
103
+ const listed = results.flatMap((result, order) => {
82
104
  const exchange = listedExchange(result);
83
105
  const type = listedType(result);
84
106
  const named = namedByService(name, result);
85
107
  const match = named ? { extra: 0, abbreviated: 0 } : companyNameMatch(name, result.name);
86
108
  if (!exchange || match == null || classifyInstrumentKind(type) === "derivative" || NOT_COMMON.test(type)
87
109
  || parsePublicTickerKey(result.symbol).exchange) return [];
88
- if (options.oneWord && (match.abbreviated > 0 || isCryptoInstrumentType(type) || !(order === 0 || named || match.extra === 0))) return [];
110
+ // A word alone names a company by these lines only; the company's other lines are still its lines.
111
+ const namesCompany = !(options.oneWord && (match.abbreviated > 0 || isCryptoInstrumentType(type) || !(order === 0 || named || match.extra === 0)));
89
112
  // A receipt is a company's shares too, however search types it ("Depositary Receipt" or "American Depositary Receipt").
90
113
  const stock = classifyInstrumentKind(type) === "equity" || RECEIPT.test(type) ? 0 : 1;
91
114
  const home = result.homeListing === true;
92
115
  const line = home ? 0 : 1 + lineDistance(type, exchange, result.symbol);
93
116
  return [{
94
- result, exchange, stock, home,
117
+ result, exchange, stock, home, namesCompany,
95
118
  rank: [match.extra, match.abbreviated, stock, line, named ? 0 : 1, isUsListingExchange(exchange) ? 0 : 1, result.symbol.length, order],
96
119
  }];
97
120
  }).sort((left, right) => {
98
121
  const at = left.rank.findIndex((value, index) => value !== right.rank[index]);
99
122
  return at < 0 ? 0 : left.rank[at]! - right.rank[at]!;
100
123
  });
101
- const first = ranked[0];
124
+ const first = listed.find((listing) => listing.namesCompany);
102
125
  // Listings are named in Latin letters: a name in another script ("任天堂 ソニー") names none, answered or not.
103
126
  if (!first && busy && !hasNonLatinLetters(name)) throw busy;
104
127
  if (!first) return null;
105
- // The first may give way to its company's other lines: as near the name and of its kind. Another line the
106
- // service marks as a home is another company's (Siemens Ltd. on NSE, for "SIEMENS AG"), and so is a line
107
- // named as that one is (Siemens Ltd. on BSE).
108
- const otherHomes = new Set(ranked.filter((listing) => listing.home && listing !== first).map(({ result }) => plainName(result.name)));
109
- otherHomes.delete(plainName(first.result.name));
110
- const lines = ranked.filter((listing) => listing === first || (!listing.home && !otherHomes.has(plainName(listing.result.name))
111
- && sameCompany(first.result.name, listing.result.name)
112
- && listing.rank.slice(0, NAME_RANKS).every((value, index) => value === first.rank[index])));
113
- const best = options.quoted ? await firstQuoted(dataProvider, lines) : first;
114
- const found = { symbol: best.result.symbol.toUpperCase(), exchange: best.exchange, name: best.result.name };
128
+ // Another line the service marks as a home is another company's (Siemens Ltd. on NSE, for "SIEMENS AG"), and
129
+ // so is a line named as that one is (Siemens Ltd. on BSE).
130
+ const otherHomesThan = (lead: typeof first) => {
131
+ const homes = new Set(listed.filter((listing) => listing.home && listing !== lead).map(({ result }) => plainName(result.name)));
132
+ homes.delete(plainName(lead.result.name));
133
+ return homes;
134
+ };
135
+ // The listing `search` calls the company's primary leads, though its name says more than the one typed: the
136
+ // name typed opens what a search for it lists first (LHA on XETRA for "lufthansa", not LUFTHANSA on Budapest).
137
+ const named = namedByService(name, first.result);
138
+ const firstHomes = otherHomesThan(first);
139
+ // Never a line of another business its name is in (`companyLines`): Siemens Energy AG is not Siemens AG's.
140
+ const lead = first.home ? first : primaryCompanyLine(first, companyLines(first, listed, lineFacts).filter((listing) => listing === first
141
+ || (!listing.home && !firstHomes.has(plainName(listing.result.name)) && listing.stock === first.stock
142
+ && (!named || companyNameExtraWords(listing.result.name, first.result.name) != null))),
143
+ ({ result, exchange }) => ({ distance: lineDistance(listedType(result), exchange, result.symbol), score: result.popularity ?? null }),
144
+ { named });
145
+ // The lead may give way to its company's other lines: as near the name and of its kind.
146
+ const otherHomes = otherHomesThan(lead);
147
+ const lines = [lead, ...companyLines(lead, listed, lineFacts).filter((listing) => listing !== lead && !listing.home
148
+ && !otherHomes.has(plainName(listing.result.name))
149
+ && listing.rank.slice(0, NAME_RANKS).every((value, index) => value === lead.rank[index]))];
150
+ const quoted = options.quoted ? await firstQuoted(dataProvider, lines, options.movedLine) : { line: lead, moved: null };
151
+ const best = quoted.line;
152
+ const found: CompanyNameListing = quoted.moved
153
+ // The line it moved to is a US symbol, read bare as `quote` reads one (GETYQ, which trades over the counter).
154
+ ? { symbol: quoted.moved.symbol, exchange: "", name: best.result.name, moved: quoted.moved.note }
155
+ : {
156
+ symbol: best.result.symbol.toUpperCase(), exchange: best.exchange, name: best.result.name,
157
+ ...namedByService(name, best.result) ? { named: true as const } : {},
158
+ };
115
159
  const meant = best.stock && options.meant ? await stockNamedRunTogether(dataProvider, name, results) : null;
116
160
  return meant ? { ...found, meant } : found;
117
161
  }
@@ -127,22 +171,41 @@ interface RankedListing {
127
171
  exchange: string;
128
172
  }
129
173
 
174
+ /** A US exchange, or the US over-the-counter market a line moves to when it leaves one. */
175
+ function isUsVenue(exchange: string): boolean {
176
+ return isUsListingExchange(exchange) || exchange.startsWith("OTC") || exchange === "PNK";
177
+ }
178
+
130
179
  /**
131
180
  * The first of a company's lines, in their order, that has a quote: the first
132
181
  * itself, or when it has none, the first of the next few that does, asked
133
- * together. The first stands when none has a quote, when the source is too
134
- * busy to say, and when nothing here can ask for quotes.
182
+ * together. A first US line whose company moved to another symbol (GETY
183
+ * became GETYQ) is followed there before any other line, so a name never lands
184
+ * on a listing abroad that still quotes the old shares. The first stands when none has a quote,
185
+ * when the source is too busy to say, and when nothing here can ask for quotes.
135
186
  */
136
- async function firstQuoted<T extends RankedListing>(dataProvider: Partial<Pick<DataProvider, "getQuote">>, lines: readonly T[]): Promise<T> {
187
+ async function firstQuoted<T extends RankedListing>(
188
+ dataProvider: Partial<Pick<DataProvider, "getQuote">>,
189
+ lines: readonly T[],
190
+ movedLine?: MovedLineLookup,
191
+ ): Promise<{ line: T; moved: { symbol: string; note: string } | null }> {
137
192
  const first = lines[0]!;
138
- if (!dataProvider.getQuote) return first;
139
- const quoted = ({ result, exchange }: RankedListing) => dataProvider.getQuote!(result.symbol.toUpperCase(), exchange).then(hasPrice);
193
+ const only = (line: T) => ({ line, moved: null });
194
+ if (!dataProvider.getQuote) return only(first);
195
+ const quoteOf = ({ result, exchange }: RankedListing) => dataProvider.getQuote!(result.symbol.toUpperCase(), exchange);
196
+ let quote: Quote | null = null;
140
197
  try {
141
- if (await quoted(first)) return first;
198
+ quote = await quoteOf(first) ?? null;
142
199
  } catch (error) {
143
200
  // A busy source says nothing of the listing; any other failure is a listing with no quote.
144
- if (isProviderBusy(error)) return first;
201
+ if (isProviderBusy(error)) return only(first);
202
+ }
203
+ if (movedLine && isUsVenue(first.exchange)) {
204
+ const symbol = first.result.symbol.toUpperCase();
205
+ const moved = await movedLine({ symbol, exchange: first.exchange, quote, name: first.result.name?.trim() || null });
206
+ if (moved && moved.symbol.toUpperCase() !== symbol) return { line: first, moved: { ...moved, symbol: moved.symbol.toUpperCase() } };
145
207
  }
208
+ if (hasPrice(quote)) return only(first);
146
209
  const keys = new Set([listingKey(first)]);
147
210
  const next: T[] = [];
148
211
  for (const line of lines) {
@@ -151,14 +214,19 @@ async function firstQuoted<T extends RankedListing>(dataProvider: Partial<Pick<D
151
214
  keys.add(listingKey(line));
152
215
  next.push(line);
153
216
  }
154
- const priced = await Promise.all(next.map((line) => quoted(line).catch(() => {
217
+ const priced = await Promise.all(next.map((line) => quoteOf(line).then(hasPrice).catch(() => {
155
218
  // Intentional ignore: a line whose quote fails is a line with no quote.
156
219
  return false;
157
220
  })));
158
- return next.find((_, index) => priced[index]) ?? first;
221
+ return only(next.find((_, index) => priced[index]) ?? first);
159
222
  }
160
223
 
161
224
  /** A name as two venues may spell it alike: "SIEMENS LTD" for Siemens Ltd. */
225
+ /** A listing as `companyLines` weighs it. */
226
+ function lineFacts({ result, exchange, home }: { result: InstrumentSearchResult; exchange: string; home: boolean }) {
227
+ return { name: result.name, exchange, receipt: RECEIPT.test(listedType(result)), home };
228
+ }
229
+
162
230
  function plainName(name: string | null | undefined): string {
163
231
  return (name ?? "").normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
164
232
  }
@@ -171,6 +239,20 @@ function hasPrice(quote: { price?: number } | null | undefined): boolean {
171
239
  return !!quote && Number.isFinite(quote.price) && quote.price !== 0;
172
240
  }
173
241
 
242
+ /**
243
+ * Whether search marks a listing under another symbol with the word as typed
244
+ * (`searchAliases`): "siemens" marks SIE on XETRA, whose symbol is not the
245
+ * word. One search, the one `findListingOfCompanyName` asks first, so asking
246
+ * this before it costs nothing more.
247
+ */
248
+ export async function marksAnotherListing(dataProvider: Partial<Pick<DataProvider, "search">>, word: string): Promise<boolean> {
249
+ // A source that cannot search marks nothing.
250
+ if (typeof dataProvider.search !== "function") return false;
251
+ const answer = await searchListings(dataProvider as Pick<DataProvider, "search">, word.trim());
252
+ return Array.isArray(answer) && answer.some((result) =>
253
+ result.symbol.toUpperCase() !== word.trim().toUpperCase() && namedByService(word, result));
254
+ }
255
+
174
256
  function searchListings(dataProvider: Pick<DataProvider, "search">, query: string): Promise<InstrumentSearchResult[] | ProviderBusyError> {
175
257
  return dataProvider.search(query, { limit: LISTING_SEARCH_LIMIT })
176
258
  .catch((error: unknown): InstrumentSearchResult[] | ProviderBusyError => {
@@ -188,18 +270,6 @@ function listedType(result: InstrumentSearchResult): string {
188
270
  return result.brokerContract?.secType || result.type || "";
189
271
  }
190
272
 
191
- /** Letters and digits only, in capitals: "JP Morgan" and "JPMorgan" are JPMORGAN. */
192
- function runTogether(text: string): string {
193
- return text.normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase().replace(/[^\p{L}\p{N}]/gu, "");
194
- }
195
-
196
- /** Whether the service marked the row with the name as typed: the company it means by it. */
197
- export function namedByService(typed: string, result: InstrumentSearchResult): boolean {
198
- const wanted = runTogether(typed);
199
- return !!wanted && Array.isArray(result.searchAliases)
200
- && result.searchAliases.some((alias) => typeof alias === "string" && runTogether(alias) === wanted);
201
- }
202
-
203
273
  /**
204
274
  * The company's common stock on a US exchange whose name starts with the
205
275
  * typed words run together ("JPMorgan Chase & Co." for "JP Morgan"), first in
@@ -1,3 +1,5 @@
1
+ import type { InstrumentSearchResult } from "../types/instrument";
2
+
1
3
  /**
2
4
  * Company names as venues spell them, compared and shortened for sentences.
3
5
  * One company's listings carry its name with different legal forms and share
@@ -9,7 +11,7 @@
9
11
  const NAME_NOISE = new Set([
10
12
  "co", "company", "corp", "corporation", "inc", "incorporated", "ltd", "limited", "llc", "lp", "plc", "sa", "ag", "nv", "se",
11
13
  "spa", "ab", "as", "asa", "oyj", "bv", "gmbh", "kgaa", "bhd", "berhad", "pcl", "the", "adr", "ads", "cedear", "cedears",
12
- "sponsored", "unsponsored", "cdr", "cdi",
14
+ "sponsored", "unsponsored", "cdr", "cdi", "aktiengesellschaft", "aktiebolag",
13
15
  ]);
14
16
  // Words a venue adds to the share it lists ("Microsoft Corporation Common Stock"), never part of whose it is.
15
17
  const SHARE_NOISE = new Set(["common", "ordinary", "stock", "shares"]);
@@ -43,6 +45,56 @@ export function sameCompany(left: string | null | undefined, right: string | nul
43
45
  return [...fewer].every((word) => more.has(word));
44
46
  }
45
47
 
48
+ // Words that tell one class of a company's shares from another ("Berkshire Hathaway Inc. Class B"), never its business.
49
+ const SHARE_CLASS_WORD = /^(?:class|series|[a-z]|\d+)$/;
50
+
51
+ /** Whether two names differ only in how they name a class of shares, so they are one company's whatever the venue. */
52
+ function sameBusinessWords(left: Set<string>, right: Set<string>): boolean {
53
+ const differ = [...left].filter((word) => !right.has(word)).concat([...right].filter((word) => !left.has(word)));
54
+ return differ.every((word) => SHARE_CLASS_WORD.test(word));
55
+ }
56
+
57
+ /** A company line as `companyLines` weighs it. */
58
+ export interface CompanyLineFacts {
59
+ name: string | null | undefined;
60
+ /** The line's canonical venue. */
61
+ exchange: string;
62
+ /** A depositary receipt, whose name says whose shares it holds rather than whose it is. */
63
+ receipt: boolean;
64
+ /** The service marks it as its company's home line (`homeListing`). */
65
+ home: boolean;
66
+ }
67
+
68
+ /**
69
+ * The lines among `lines` that are `lead`'s company's own (`sameCompany`),
70
+ * less those under a name that adds words for another business, though one
71
+ * name's words are all in the other's: Siemens Energy AG and Siemens
72
+ * Healthineers AG are not Siemens AG, nor Sony Financial Group Sony Group. A
73
+ * name is another business's where a venue lists a line under it beside a
74
+ * line under the lead's own name (both on XETRA, both in Tokyo), or where it
75
+ * and a line under the lead's name are each marked as a home line, since a
76
+ * company has one (Siemens Ltd. on NSE beside Siemens AG on XETRA). Receipts
77
+ * are named for the shares they hold, so they never tell two businesses
78
+ * apart, and nor do words that name a class of shares (Class B). A name with
79
+ * more words on no shared venue stays the company's (Deutsche Lufthansa AG on
80
+ * XETRA for LUFTHANSA on Budapest). `lead` is always one of them.
81
+ */
82
+ export function companyLines<T>(lead: T, lines: readonly T[], facts: (line: T) => CompanyLineFacts): T[] {
83
+ const own = facts(lead);
84
+ const ownWords = companyWords(own.name);
85
+ const ownName = (line: CompanyLineFacts) => sameBusinessWords(companyWords(line.name), ownWords);
86
+ const candidates = lines.filter((line) => line === lead || sameCompany(own.name, facts(line).name));
87
+ const read = candidates.map((line) => ({ line, facts: facts(line), words: companyWords(facts(line).name) }));
88
+ const ownShares = read.filter((entry) => !entry.facts.receipt && ownName(entry.facts));
89
+ const ownVenues = new Set(ownShares.map((entry) => entry.facts.exchange).filter(Boolean));
90
+ const ownHome = ownShares.some((entry) => entry.facts.home);
91
+ // A name another business goes by, once one of its share lines gives it away: all its lines are that business's.
92
+ const others = read.filter((entry) => !entry.facts.receipt && !ownName(entry.facts)
93
+ && ((!!entry.facts.exchange && ownVenues.has(entry.facts.exchange)) || (entry.facts.home && ownHome)));
94
+ const otherBusiness = (words: Set<string>) => others.some((other) => sameBusinessWords(other.words, words));
95
+ return read.filter((entry) => entry.line === lead || ownName(entry.facts) || !otherBusiness(entry.words)).map((entry) => entry.line);
96
+ }
97
+
46
98
  /**
47
99
  * How many words a listing's name has beyond a company name typed for it,
48
100
  * which it must have every word of: 0 for the same name however each spells
@@ -215,10 +267,15 @@ function nameWord(word: string, opening: boolean, domain: boolean): string {
215
267
  * the way its source means it and comes back as it is.
216
268
  */
217
269
  export function companyNameCase(name: string): string {
218
- if (!/\p{Lu}/u.test(name) || name !== name.toUpperCase()) return name;
270
+ // A brand that opens in lower case before capitals (aTYR, iROBOT) is still a name in capitals: aTyr, iRobot.
271
+ const brand = /^\p{Ll}(?=\p{Lu}{2})/u;
272
+ const capitals = name.split(/\s+/).map((word) => word.replace(brand, "")).join(" ");
273
+ if (!/\p{Lu}/u.test(name) || capitals !== capitals.toUpperCase()) return name;
219
274
  let words = 0;
220
275
  return name.replace(/\S+/g, (token) => {
221
276
  const opening = words++ === 0;
277
+ const lead = brand.exec(token)?.[0];
278
+ if (lead) return `${lead}${token.charAt(1)}${token.slice(2).toLowerCase()}`;
222
279
  // A separator inside a word (Coca-Cola, AT&T, Amazon.com, McDonald's) cuts it into words of its own.
223
280
  return token.split(/([-/.&,'\u2019]+)/).map((part, index, parts) => {
224
281
  if (index % 2 === 1) return part;
@@ -229,3 +286,15 @@ export function companyNameCase(name: string): string {
229
286
  }).join("");
230
287
  });
231
288
  }
289
+
290
+ /** Letters and digits only, in capitals: "JP Morgan" and "JPMorgan" are JPMORGAN. */
291
+ export function runTogether(text: string): string {
292
+ return text.normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase().replace(/[^\p{L}\p{N}]/gu, "");
293
+ }
294
+
295
+ /** Whether the service marked the row with the name as typed: the company it means by it. */
296
+ export function namedByService(typed: string, result: Pick<InstrumentSearchResult, "searchAliases">): boolean {
297
+ const wanted = runTogether(typed);
298
+ return !!wanted && Array.isArray(result.searchAliases)
299
+ && result.searchAliases.some((alias) => typeof alias === "string" && runTogether(alias) === wanted);
300
+ }
@@ -45,3 +45,10 @@ export function indexRootSuggestion(symbol: string): string | null {
45
45
  const root = indexRootFor(symbol);
46
46
  return root ? `Did you mean ${root.symbol} (${root.name})?` : null;
47
47
  }
48
+
49
+ /** Cboe's Treasury yield indices: 13-week bill, 5-, 10- and 30-year notes. Each level is a yield in percent. */
50
+ const YIELD_INDEX_SYMBOLS: ReadonlySet<string> = new Set(["^IRX", "^FVX", "^TNX", "^TYX"]);
51
+
52
+ export function isYieldIndexSymbol(symbol: string): boolean {
53
+ return YIELD_INDEX_SYMBOLS.has(symbol.trim().toUpperCase());
54
+ }
@@ -182,7 +182,7 @@ export function describeListingFate(
182
182
  * ended long ago. Only such a line can be a company that moved away, so only
183
183
  * its change lookup is waited for.
184
184
  */
185
- function mayHaveMoved(quote: Quote | null, now = Date.now()): boolean {
185
+ export function lineMayHaveMoved(quote: Quote | null, now = Date.now()): boolean {
186
186
  return !!quote && (isQuoteBeforeLatestSession(quote, now) || endedTradingDate(quote, now) != null);
187
187
  }
188
188
 
@@ -206,7 +206,7 @@ export async function loadListingFate(
206
206
  // A lookup started beside the command's request gets a short grace once that is ready, so a slow
207
207
  // one never holds a symbol that trades. A line that stopped is waited for: only its company can
208
208
  // have moved, and then the answer decides whether the command serves anything at all.
209
- const changes = await lookup.settle(!options.changes || mayHaveMoved(quote) ? LATE_CHANGE_WAIT_MS : undefined);
209
+ const changes = await lookup.settle(!options.changes || lineMayHaveMoved(quote) ? LATE_CHANGE_WAIT_MS : undefined);
210
210
  return describeListingFate(listing, quote, changes);
211
211
  } catch (error) {
212
212
  logFailure("tickers-listing-fate", "Could not resolve listing fate")(error);
@@ -1,5 +1,5 @@
1
- import { canonicalExchange } from "../../utils/exchanges";
2
- import { companyNameExtraWords, sameCompany } from "../company-name";
1
+ import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
2
+ import { companyLines, companyNameExtraWords, namedByService, type CompanyLineFacts } from "../company-name";
3
3
  import { answersOnlyByKeyword, isQualifiedTickerQuery } from "./ranking";
4
4
  import type { TickerSearchCandidate } from "./types";
5
5
 
@@ -29,6 +29,69 @@ export function lineDistance(type: string, exchange: string, symbol: string): 0
29
29
  return SECONDARY_VENUES.has(exchange) || /\d/.test(symbol) && /[A-Za-z]/.test(symbol) ? 1 : 0;
30
30
  }
31
31
 
32
+ /** How the primary-listing choice weighs one of a company's lines. */
33
+ export interface WeighedCompanyLine {
34
+ /** `listingDistance`: 0 its own line, 1 a line quoting shares listed elsewhere, 2 a receipt. */
35
+ distance: number;
36
+ /** The search score the service gives the listings people look up most; null when it gives none. */
37
+ score: number | null;
38
+ }
39
+
40
+ /**
41
+ * The line a company's search lists as its primary listing instead of `lead`,
42
+ * the line search put first, often the one-off cross-listing whose short name
43
+ * matched the query (LUFTHANSA on Budapest): its lines go by how far each is
44
+ * from being its own line, then by the search score. The lead gives way only
45
+ * to a closer line, or to one searched at least twice as often, or any scored
46
+ * line when it has no score. With `named` (the service marked the lead with
47
+ * the name as typed) no score outweighs the mark: only a closer line does.
48
+ * `lines` holds the lead. `search` and a company's name typed for a listing
49
+ * (`findListingOfCompanyName`) choose by this one rule, so both open the same
50
+ * listing.
51
+ */
52
+ export function primaryCompanyLine<T>(
53
+ lead: T,
54
+ lines: readonly T[],
55
+ weigh: (line: T) => WeighedCompanyLine,
56
+ options: { named?: boolean } = {},
57
+ ): T {
58
+ const order = (line: T) => lines.indexOf(line);
59
+ const [home] = [...lines].sort((a, b) => weigh(a).distance - weigh(b).distance
60
+ || (weigh(b).score ?? -1) - (weigh(a).score ?? -1) || order(a) - order(b));
61
+ if (!home || home === lead) return lead;
62
+ const [best, first] = [weigh(home), weigh(lead)];
63
+ const closer = best.distance < first.distance;
64
+ const moreSearched = !options.named && best.distance === first.distance && best.score != null
65
+ && (first.score == null || best.score >= first.score * 2);
66
+ return closer || moreSearched ? home : lead;
67
+ }
68
+
69
+ /** A row as `companyLines` weighs it. */
70
+ function lineFacts(candidate: TickerSearchCandidate): CompanyLineFacts {
71
+ return {
72
+ name: candidateName(candidate),
73
+ exchange: candidateExchange(candidate),
74
+ receipt: listingDistance(candidate) === 2,
75
+ home: candidate.result?.homeListing === true,
76
+ };
77
+ }
78
+
79
+ /** The rows of `lead`'s company among `items` (`companyLines`): never a line of another business its name is in (Siemens Energy for Siemens). */
80
+ function companyRows<T extends TickerSearchCandidate>(lead: T, items: readonly T[]): T[] {
81
+ return companyLines(lead, items.filter((item) => item === lead || isListedEquity(item)), lineFacts);
82
+ }
83
+
84
+ /** `primaryCompanyLine` among `items` of `start`'s company, for the command bar's rows. */
85
+ function primaryLineOf<T extends TickerSearchCandidate>(start: T, items: readonly T[], query: string): T {
86
+ const name = candidateName(start);
87
+ const named = !!start.result && namedByService(query, start.result);
88
+ const lines = companyRows(start, items).filter((item) => item === start
89
+ || !named || companyNameExtraWords(candidateName(item), name) != null);
90
+ return primaryCompanyLine(start, lines, (item) => ({
91
+ distance: listingDistance(item), score: item.result?.popularity ?? item.popularity ?? null,
92
+ }), { named });
93
+ }
94
+
32
95
  function candidateName(candidate: TickerSearchCandidate): string {
33
96
  return candidate.result?.name || candidate.detail.split(" | ")[0] || "";
34
97
  }
@@ -51,24 +114,46 @@ function isListedEquity(candidate: TickerSearchCandidate): boolean {
51
114
  * service marks as the company's home (`homeListing`) takes the lead from
52
115
  * the company's first row. Without that mark, a receipt in the lead gives
53
116
  * way to the company's own shares on an exchange (`listingDistance` 0, not
54
- * an OTC or regional line), the name nearest the receipt's first. A query that names a venue (LVMH:TSX,
55
- * 7203.T) asked for that line, and a saved row keeps the lead.
117
+ * an OTC or regional line), the name nearest the receipt's first. Then the
118
+ * line `search` calls the company's primary listing (`primaryCompanyLine`)
119
+ * takes the lead, so a name opens what `search` lists first: LHA on XETRA
120
+ * for "lufthansa", not LUFTHANSA on Budapest. A company's lines are never
121
+ * another business's its name is in (`companyLines`: Siemens Energy AG for
122
+ * Siemens AG). The row the service marks with the word as typed is the
123
+ * company the word means, ahead of a line abroad whose symbol is the word:
124
+ * "siemens" is SIE on XETRA, not SIEMENS on NSE (Siemens Ltd.); a US line
125
+ * keeps its ticker. A query that names a venue (LVMH:TSX, 7203.T) asked for
126
+ * that line, and a saved row keeps the lead.
56
127
  */
57
128
  export function withHomeListingLead<T extends TickerSearchCandidate>(items: T[], query: string): T[] {
58
129
  if (isQualifiedTickerQuery(query)) return items;
59
130
  const lead = items.find((item) => item.instrumentClass !== "fund" && item.instrumentClass !== "derivative"
60
131
  && !answersOnlyByKeyword(item, query));
61
132
  if (!lead || !isListedEquity(lead)) return items;
133
+ const typedSymbol = lead.symbol.toUpperCase() === query.trim().toUpperCase();
134
+ const meant = typedSymbol && !isUsListingExchange(candidateExchange(lead)) && !(lead.result && namedByService(query, lead.result))
135
+ // A company's shares under another symbol: an index the code also names (SPX) or the word's own line elsewhere (SAP) is not it.
136
+ ? items.find((item) => item !== lead && isListedEquity(item) && item.instrumentClass === "equity" && !!item.result
137
+ && item.symbol.toUpperCase() !== lead.symbol.toUpperCase() && namedByService(query, item.result))
138
+ : undefined;
139
+ if (meant) return moveAhead(items, meant, lead);
62
140
  const name = candidateName(lead);
63
- const lines = items.filter((item) => item !== lead && isListedEquity(item) && sameCompany(name, candidateName(item)));
141
+ const lines = companyRows(lead, items).filter((item) => item !== lead);
64
142
  const nearest = (candidates: T[]) => [...candidates].sort((a, b) => nameGap(name, candidateName(a)) - nameGap(name, candidateName(b))
65
143
  || items.indexOf(a) - items.indexOf(b))[0];
66
144
  const marked = lines.filter((item) => item.result?.homeListing === true);
145
+ const own = listingDistance(lead) === 2 ? nearest(lines.filter((item) => listingDistance(item) === 0)) : undefined;
67
146
  const home = lead.result?.homeListing === true ? undefined
68
147
  : marked.length ? nearest(marked)
69
- : listingDistance(lead) === 2 ? nearest(lines.filter((item) => listingDistance(item) === 0))
70
- : undefined;
71
- if (!home) return items;
148
+ // A listing searched by its own symbol is that listing, as `search` keeps it.
149
+ : typedSymbol ? own
150
+ : primaryLineOf(own ?? lead, [lead, ...lines], query);
151
+ if (!home || home === lead) return items;
152
+ return moveAhead(items, home, lead);
153
+ }
154
+
155
+ /** `items` with `home` moved just ahead of `lead`. */
156
+ function moveAhead<T>(items: readonly T[], home: T, lead: T): T[] {
72
157
  const rest = items.filter((item) => item !== home);
73
158
  const at = rest.indexOf(lead);
74
159
  return [...rest.slice(0, at), home, ...rest.slice(at)];
@@ -7,6 +7,7 @@ import { getListingSymbol, listingSuffixConflictsWithExchange, tickerHasListingS
7
7
  import { parseOptionSymbol } from "../../utils/options";
8
8
  import { resolveCurrencyUnit } from "../../utils/currency-units";
9
9
  import { currencyPairSpelling } from "../../utils/currency-pair";
10
+ import { typedCoinPairListing } from "../../utils/crypto-pair";
10
11
  import { assetClassMarketSymbol, parseAssetClassQuery } from "./asset-classes";
11
12
  import { bareFuturesCode, futuresContractByCode } from "../../market-data/futures-board";
12
13
  import { searchContractKey, searchInstrumentKey } from "./identity";
@@ -53,7 +54,7 @@ export {
53
54
  rankTickerSearchItems,
54
55
  } from "./ranking";
55
56
  export { upsertTickerFromSearchResult } from "./upsert";
56
- export { candidateExchange, listingDistance } from "./home-listing";
57
+ export { candidateExchange, listingDistance, primaryCompanyLine } from "./home-listing";
57
58
 
58
59
  const MAX_NAMED_LISTINGS = 6;
59
60
 
@@ -387,9 +388,12 @@ export function resolveTickerSearch(options: ResolveTickerSearchOptions): Promis
387
388
  // JPY=X are EURUSD=X and USDJPY=X. USD-NGN and USDNGN, which a security could also be written as
388
389
  // (MNT-USD is a coin), are the pair only when nothing is listed under those letters.
389
390
  const pair = pickListing || future ? null : currencyPairSpelling(typed);
390
- const symbol = future?.symbol ?? (pair && !pair.ambiguous ? pair.symbol : typed);
391
+ // A coin pair written as crypto exchanges write it (BTC/GBP, or BTC/GBP:KRAKEN) is the crypto venue's BTC-GBP,
392
+ // and what was typed when the venue has no such coin.
393
+ const coin = pickListing || future || pair || options.tickers.has(typed) ? null : typedCoinPairListing(typed);
394
+ const symbol = future?.symbol ?? coin?.symbol ?? (pair && !pair.ambiguous ? pair.symbol : typed);
391
395
  const literal = resolveTickerSymbol(symbol, options, pickListing ? requested : null);
392
- const alternatives = pickListing ? [] : [...dottedVenueQueries(symbol), ...pair?.ambiguous ? [pair.symbol] : []];
396
+ const alternatives = pickListing ? [] : [...dottedVenueQueries(symbol), ...pair?.ambiguous ? [pair.symbol] : [], ...coin ? [typed] : []];
393
397
  // A listing suffix search does not carry (CEZ.PR) is read from its own quote, as `quote` reads it.
394
398
  const quoted = !pickListing && tickerHasListingSuffix(symbol) && !parsePublicTickerKey(symbol).exchange;
395
399
  // Most input has no venue code to retry: hand back the literal lookup
@@ -4,6 +4,7 @@ import type {
4
4
  } from "./types";
5
5
  import { getListingSymbol, tickerHasListingSuffix } from "../../sources/listing-symbols";
6
6
  import { currencyPairCode } from "../../utils/currency-pair";
7
+ import { typedCoinPairListing } from "../../utils/crypto-pair";
7
8
  import { canonicalExchange, exchangeCodeVenues, isUsListingExchange, parsePublicTickerKey, typedExchangeCode } from "../../utils/exchanges";
8
9
  import { assetClassKeeps, assetClassMarketSymbol, instrumentClassCode, parseAssetClassQuery } from "./asset-classes";
9
10
  import { futuresContractByCode } from "../../market-data/futures-board";
@@ -322,9 +323,12 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
322
323
  .map(({ item }) => getTickerSearchListingKey(item)),
323
324
  );
324
325
 
326
+ // BTC/GBP reads as a currency pair's spelling, and is the crypto venue's BTC-GBP.
327
+ const coinListing = typedCoinPairListing(query)?.symbol;
325
328
  const filtered = ranked.filter(({ item, textScore }) => {
326
329
  if (textScore <= 0) return false;
327
- if (isExplicitMarketSymbol(query) && !isExplicitMarketSymbol(item.symbol || item.label)) return false;
330
+ if (isExplicitMarketSymbol(query) && !isExplicitMarketSymbol(item.symbol || item.label)
331
+ && parsePublicTickerKey(item.symbol || item.label).symbol !== coinListing) return false;
328
332
  if (item.kind !== "search") return true;
329
333
  return !matchedLocalListings.has(getTickerSearchListingKey(item));
330
334
  });
@@ -11,6 +11,7 @@ import {
11
11
  import { canonicalExchange, parsePublicTickerKey, publicTickerKey } from "../../utils/exchanges";
12
12
  import { scopedBrokerContractIdentityKey } from "../../utils/instrument-identity";
13
13
  import { listingSuffixVenue } from "../../sources/listing-symbols";
14
+ import { coinPairQuoteCurrency, exchangeLineCoinListing, isCoinRowType } from "../../utils/crypto-pair";
14
15
 
15
16
  /**
16
17
  * The key a result is saved under when its symbol is already saved for
@@ -36,6 +37,18 @@ export async function upsertTickerFromSearchResult(
36
37
  result: InstrumentSearchResult,
37
38
  options: { tickerSymbol?: string } = {},
38
39
  ): Promise<{ ticker: TickerRecord; created: boolean }> {
40
+ // A crypto exchange's line of a coin pair (BTC/GBP on COINBASE PRO), which no source quotes, is saved as the
41
+ // crypto venue's listing of the pair (BTC-GBP on CCC), in the currency the pair is quoted in.
42
+ const coin = result.brokerContract ? null
43
+ : exchangeLineCoinListing(getSearchResultSymbol(result), listingVenue(result), { coinRow: isCoinRowType(result.type) });
44
+ if (coin) {
45
+ result = { ...result, symbol: coin.symbol, exchange: coin.exchange, primaryExchange: undefined, currency: coin.currency };
46
+ if (options.tickerSymbol) options = { ...options, tickerSymbol: publicTickerKey(coin.symbol, coin.exchange) };
47
+ } else if (!result.currency && !result.brokerContract && canonicalExchange(listingVenue(result)) === "CCC") {
48
+ // The venue's own row of a pair (BTC-GBP) often comes without its currency, which the pair names.
49
+ const currency = coinPairQuoteCurrency(getSearchResultSymbol(result));
50
+ if (currency) result = { ...result, currency };
51
+ }
39
52
  let symbol = options.tickerSymbol ?? getSearchResultSymbol(result);
40
53
  // A vendor's suffixed symbol (GARAN.IS) is saved as the listing it is (GARAN on BIST), under the key
41
54
  // GARAN:BIST is saved under, so one listing is one row. A key the caller names is kept as given.