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
@@ -5,6 +5,7 @@ import type { HeadlessPaneFreshness } from "../../../types/headless";
5
5
  import type { TickerRecord } from "../../../types/ticker";
6
6
  import { formatCompact } from "../../../utils/format";
7
7
  import { compareSortValues, type SortPreference } from "../../../utils/sort-values";
8
+ import { typoDistance } from "../../../utils/typo-distance";
8
9
  import { formatMove } from "../credit-boards/model";
9
10
 
10
11
  export const CDS_PANE_ID = "cds";
@@ -20,7 +21,7 @@ const DECIMAL_TO_BP = 10_000;
20
21
 
21
22
  export interface CdsTrade {
22
23
  id: string;
23
- /** Reported spelling, kept verbatim so a display name can be chosen later. */
24
+ /** The issuer's best spelling on the tape, the same on every print of it (see `normalizeCdsTrades`). */
24
25
  issuer: string;
25
26
  /** Alias-collapsed grouping key. Internal only; never shown or sent. */
26
27
  issuerKey: string;
@@ -31,8 +32,13 @@ export interface CdsTrade {
31
32
  notionalCapped: boolean;
32
33
  currency: string | null;
33
34
  couponBp: number | null;
34
- /** Only what the report carried. Never derived from upfront or coupon. */
35
+ /**
36
+ * Only what the report carried. Never derived from upfront or coupon, and
37
+ * empty when the field held points upfront (`pointsUpfront`).
38
+ */
35
39
  spreadBp: number | null;
40
+ /** The quote of a print whose spread field held points upfront, not bp. */
41
+ pointsUpfront: number | null;
36
42
  /** Source inputs retained even when their notation cannot supply a bp rate. */
37
43
  reportedSpread: number | null;
38
44
  spreadNotation: string | null;
@@ -143,6 +149,66 @@ export function issuerGroupKey(name: string): string {
143
149
  return (meaningful.length > 0 ? meaningful : tokens).join(" ") || name.trim().toLowerCase();
144
150
  }
145
151
 
152
+ /** Fewer letters than this are a symbol, not words of a name: `f` is Ford's ticker, not "F&G Annuities & Life". */
153
+ const MIN_NAME_LETTERS = 3;
154
+
155
+ /**
156
+ * The covered issuer a name stands for, by its words: the issuer of that very
157
+ * name, else one whose name holds those words in a row (`goodyear` is "The
158
+ * Goodyear Tire & Rubber Company"), one that starts with them first, then the
159
+ * shortest, so `ford` is Ford Motor Company and not Ford Motor Credit. Null
160
+ * when none does, and for fewer than three letters (`f`, `ge`), which are a
161
+ * symbol to look up.
162
+ */
163
+ export function coveredIssuerMatch(name: string, covered: readonly string[]): string | null {
164
+ const wanted = issuerGroupKey(displayIssuerName(name));
165
+ if (wanted.replace(/[^\p{L}\p{N}]/gu, "").length < MIN_NAME_LETTERS) return null;
166
+ let best: { issuer: string; leading: boolean; words: number } | null = null;
167
+ for (const issuer of covered) {
168
+ const key = issuerGroupKey(displayIssuerName(issuer));
169
+ if (key === wanted) return displayIssuerName(issuer);
170
+ if (!` ${key} `.includes(` ${wanted} `)) continue;
171
+ const candidate = { issuer: displayIssuerName(issuer), leading: key.startsWith(`${wanted} `), words: key.split(" ").length };
172
+ if (!best
173
+ || (candidate.leading !== best.leading ? candidate.leading
174
+ : candidate.words !== best.words ? candidate.words < best.words
175
+ : candidate.issuer.localeCompare(best.issuer) < 0)) best = candidate;
176
+ }
177
+ return best?.issuer ?? null;
178
+ }
179
+
180
+ /** How many covered issuers an unmatched name offers. */
181
+ const CLOSEST_ISSUERS = 3;
182
+
183
+ /**
184
+ * How near a word is to one of a name's: a slip of the keys, or the same first
185
+ * letters (`goodyer`, `paramo`). Short words never count, or every name with
186
+ * "co" in it would be close.
187
+ */
188
+ function wordCloseness(typed: string, word: string): number {
189
+ if (typed.length < 3 || word.length < 3) return 0;
190
+ if (typed.length >= 4 && typoDistance(typed, word) <= 1) return typed.length;
191
+ let shared = 0;
192
+ while (shared < Math.min(typed.length, word.length) && typed[shared] === word[shared]) shared += 1;
193
+ return shared >= 4 || (shared >= 3 && shared === typed.length) ? shared : 0;
194
+ }
195
+
196
+ /** The covered issuers closest to a name that matched none, closest first; none when nothing is near. */
197
+ export function closestCoveredIssuers(name: string, covered: readonly string[]): string[] {
198
+ const typed = issuerGroupKey(displayIssuerName(name)).split(" ").filter(Boolean);
199
+ const scored = new Map<string, number>();
200
+ for (const issuer of covered) {
201
+ const words = issuerGroupKey(displayIssuerName(issuer)).split(" ");
202
+ const score = typed.reduce((sum, word) => sum + Math.max(0, ...words.map((other) => wordCloseness(word, other))), 0);
203
+ const shown = displayIssuerName(issuer);
204
+ if (score > 0 && score > (scored.get(shown) ?? 0)) scored.set(shown, score);
205
+ }
206
+ return [...scored]
207
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
208
+ .slice(0, CLOSEST_ISSUERS)
209
+ .map(([issuer]) => issuer);
210
+ }
211
+
146
212
  /**
147
213
  * DTCC writes a comma inside a field as `;` ("CCO Holdings;LLC",
148
214
  * "Amazon.com;Inc."), so a name gets its comma back before it is shown,
@@ -207,6 +273,37 @@ function couponToBasisPoints(fixedRate: number | null): number | null {
207
273
  return fixedRate * DECIMAL_TO_BP;
208
274
  }
209
275
 
276
+ /**
277
+ * How far a reported quote may sit from the points its upfront works out to
278
+ * and still be read as those points: accrued and rounding move the amount by
279
+ * about a tenth of a point.
280
+ */
281
+ const POINTS_MATCH_PTS = 0.5;
282
+ const POINTS_MATCH_SHARE = 0.05;
283
+
284
+ /**
285
+ * Some desks key a distressed name's points upfront into the spread field and
286
+ * leave the coupon blank: Whirlpool's Dec 2028 contract printed `0.00065`,
287
+ * 6.5 read as bp, beside 319,861 upfront on 5M, which is 6.4 points. Without a
288
+ * coupon nothing makes the field a running spread, and when the amount over
289
+ * the notional reads as the same number in points, it is that many points.
290
+ * A real spread that low on a running coupon would need the upfront to land on
291
+ * the same number by chance, below 5bp.
292
+ */
293
+ function pointsUpfrontOf(
294
+ spreadBp: number | null,
295
+ couponBp: number | null,
296
+ upfront: number | null,
297
+ notional: number | null,
298
+ ): number | null {
299
+ if (couponBp != null || spreadBp == null || !(spreadBp > 0)) return null;
300
+ if (upfront == null || !Number.isFinite(upfront) || notional == null || !(notional > 0)) return null;
301
+ const impliedPts = (Math.abs(upfront) / notional) * 100;
302
+ const tolerance = Math.max(POINTS_MATCH_PTS, impliedPts * POINTS_MATCH_SHARE);
303
+ // The decimal the field holds carries float noise (0.001525 is 15.249999...).
304
+ return Math.abs(spreadBp - impliedPts) <= tolerance ? Math.round(spreadBp * 1e6) / 1e6 : null;
305
+ }
306
+
210
307
  /**
211
308
  * When the trade was struck, not when the tape carried it. A lifecycle
212
309
  * correction is disseminated long after the fact, so using the event time would
@@ -232,32 +329,46 @@ function issuerOf(trade: CloudCdsTradePayload): string | null {
232
329
  return underlier && identifiesIssuer(underlier) ? displayIssuerName(underlier) : null;
233
330
  }
234
331
 
332
+ /**
333
+ * The tape's prints as rows. One issuer reads as one name on every print:
334
+ * the tape spells Whirlpool three ways in a week ("WHIRLPOOL CORPORATION",
335
+ * "Whirlpool Corp", "Whirlpool Corporation"), and each row takes the spelling
336
+ * the issuer list shows for them all.
337
+ */
235
338
  export function normalizeCdsTrades(trades: readonly CloudCdsTradePayload[]): CdsTrade[] {
236
339
  const rows: CdsTrade[] = [];
340
+ const names = new Map<string, string>();
237
341
  for (const trade of trades) {
238
342
  const eventAt = tapeTime(trade);
239
343
  if (eventAt == null) continue;
240
344
  const issuer = issuerOf(trade);
241
345
  // Nothing names the reference entity, so the row would invent one.
242
346
  if (!issuer) continue;
347
+ const issuerKey = issuerGroupKey(issuer);
348
+ const named = names.get(issuerKey);
349
+ names.set(issuerKey, named ? preferredIssuerName(named, issuer) : issuer);
350
+ const couponBp = couponToBasisPoints(trade.fixedRate);
351
+ const reportedBp = spreadToBasisPoints(trade.reportedSpread, trade.spreadNotation);
352
+ const pointsUpfront = pointsUpfrontOf(reportedBp, couponBp, trade.upfrontAmount, trade.notionalAmount);
243
353
  rows.push({
244
354
  id: trade.disseminationId,
245
355
  issuer,
246
- issuerKey: issuerGroupKey(issuer),
356
+ issuerKey,
247
357
  eventAt,
248
358
  maturity: trade.maturityDate ?? trade.expirationDate,
249
359
  notional: trade.notionalAmount,
250
360
  notionalCapped: trade.notionalCapped,
251
361
  currency: trade.notionalCurrency,
252
- couponBp: couponToBasisPoints(trade.fixedRate),
253
- spreadBp: spreadToBasisPoints(trade.reportedSpread, trade.spreadNotation),
362
+ couponBp,
363
+ spreadBp: pointsUpfront == null ? reportedBp : null,
364
+ pointsUpfront,
254
365
  reportedSpread: trade.reportedSpread,
255
366
  spreadNotation: trade.spreadNotation,
256
367
  upfront: trade.upfrontAmount,
257
368
  upfrontCurrency: trade.upfrontCurrency,
258
369
  });
259
370
  }
260
- return rows;
371
+ return rows.map((row) => ({ ...row, issuer: names.get(row.issuerKey) ?? row.issuer }));
261
372
  }
262
373
 
263
374
  /**
@@ -405,13 +516,23 @@ export function cdsFreshness(lastTrade: number | string | null): HeadlessPaneFre
405
516
  * What an empty view says: the window searched and, for one name, the
406
517
  * reference entity the query became, with what was typed when that differs.
407
518
  */
408
- export function noTradesMessage({ query, issuer, days }: {
519
+ export function noTradesMessage({ query, issuer, days, unmatched = null }: {
409
520
  query: string | null;
410
521
  issuer: string | null;
411
522
  days: number;
523
+ /**
524
+ * A name that matched no covered issuer, once the tape has no trades and no
525
+ * line under it either: what the view says instead is that no issuer
526
+ * matches, with the covered ones closest to what was typed.
527
+ */
528
+ unmatched?: { nearest: readonly string[] } | null;
412
529
  }): string {
413
530
  const window = `in the last ${days} days`;
414
531
  if (!query) return `No single-name CDS trades ${window}.`;
532
+ if (unmatched) {
533
+ const nearest = unmatched.nearest.length ? ` Closest covered issuers: ${unmatched.nearest.join(", ")}.` : "";
534
+ return `No CDS issuer matches ${query}.${nearest}`;
535
+ }
415
536
  const name = issuer && issuer.toUpperCase() !== query.toUpperCase() ? `${issuer} (${query})` : issuer ?? query;
416
537
  return `No CDS trades for ${name} ${window}.`;
417
538
  }
@@ -610,22 +731,25 @@ export function sortIssuers(
610
731
  });
611
732
  }
612
733
 
613
- export type TradeColumnId = "time" | "maturity" | "notional" | "currency" | "coupon" | "spread" | "upfront";
734
+ export type TradeColumnId = "time" | "maturity" | "notional" | "currency" | "coupon" | "spread" | "points" | "upfront";
614
735
  export type TradeColumn = DataTableColumn & { id: TradeColumnId };
615
736
  export type TradeSortPreference = SortPreference<TradeColumnId>;
616
737
 
617
- export const TRADE_SORT_COLUMN_IDS: readonly TradeColumnId[] = [
618
- "time",
619
- "maturity",
620
- "notional",
621
- "coupon",
622
- "spread",
623
- "upfront",
624
- ];
625
738
  export const DEFAULT_TRADE_SORT: TradeSortPreference = { columnId: "time", direction: "desc" };
626
739
 
627
- export function buildTradeColumns(width: number): TradeColumn[] {
628
- const maturityWidth = Math.max(10, Math.min(12, width - 59));
740
+ /** Whether any print was quoted in points upfront, which is when the trades show a Points column. */
741
+ export function hasPointsQuotes(trades: readonly Pick<CdsTrade, "pointsUpfront">[]): boolean {
742
+ return trades.some((trade) => trade.pointsUpfront != null);
743
+ }
744
+
745
+ /** The columns `]` and `[` cycle the trades' sort through: the ones shown. */
746
+ export function tradeSortColumnIds(points: boolean): readonly TradeColumnId[] {
747
+ return ["time", "maturity", "notional", "coupon", "spread", ...(points ? ["points" as const] : []), "upfront"];
748
+ }
749
+
750
+ /** A Points column sits beside Spread only when a print was quoted in points. */
751
+ export function buildTradeColumns(width: number, points = false): TradeColumn[] {
752
+ const maturityWidth = Math.max(10, Math.min(12, width - 59 - (points ? 9 : 0)));
629
753
  return [
630
754
  { id: "time", label: "TIME UTC", width: 16, align: "left" },
631
755
  { id: "maturity", label: "MATURITY", width: maturityWidth, align: "left" },
@@ -633,6 +757,7 @@ export function buildTradeColumns(width: number): TradeColumn[] {
633
757
  { id: "currency", label: "CCY", width: 5, align: "left" },
634
758
  { id: "coupon", label: "COUPON", width: 9, align: "right" },
635
759
  { id: "spread", label: "SPREAD", width: 10, align: "right" },
760
+ ...(points ? [{ id: "points" as const, label: "POINTS", width: 9, align: "right" as const }] : []),
636
761
  { id: "upfront", label: "ABS UPFRONT", width: 13, align: "right" },
637
762
  ];
638
763
  }
@@ -651,6 +776,8 @@ function tradeSortValue(columnId: TradeColumnId, row: CdsTrade): string | number
651
776
  return row.couponBp;
652
777
  case "spread":
653
778
  return row.spreadBp;
779
+ case "points":
780
+ return row.pointsUpfront;
654
781
  case "upfront":
655
782
  return row.upfront;
656
783
  }
@@ -40,7 +40,6 @@ import {
40
40
  DEFAULT_ISSUER_SORT,
41
41
  DEFAULT_TRADE_SORT,
42
42
  ISSUER_SORT_COLUMN_IDS,
43
- TRADE_SORT_COLUMN_IDS,
44
43
  buildIssuerColumns,
45
44
  buildTradeColumns,
46
45
  cdsFreshness,
@@ -48,9 +47,11 @@ import {
48
47
  formatEventTime,
49
48
  formatMaturity,
50
49
  formatNotional,
50
+ formatPoints,
51
51
  formatQuoted,
52
52
  formatQuotedMove,
53
53
  formatUpfront,
54
+ hasPointsQuotes,
54
55
  issuerListSort,
55
56
  lastTradeOf,
56
57
  noLevelMessage,
@@ -63,6 +64,7 @@ import {
63
64
  spreadChartPoints,
64
65
  spreadFigures,
65
66
  summarizeIssuers,
67
+ tradeSortColumnIds,
66
68
  tradesForIssuer,
67
69
  withSpreadLevels,
68
70
  type CdsIssuerSummary,
@@ -138,6 +140,11 @@ function renderTradeCell(row: CdsTrade, column: TradeColumn, lineMaturity: strin
138
140
  text: formatBp(row.spreadBp),
139
141
  color: dimmed ?? (row.spreadBp == null ? colors.textDim : colors.textBright),
140
142
  };
143
+ case "points":
144
+ return {
145
+ text: formatPoints(row.pointsUpfront),
146
+ color: dimmed ?? (row.pointsUpfront == null ? colors.textDim : colors.textBright),
147
+ };
141
148
  case "upfront":
142
149
  return {
143
150
  text: formatUpfront(row),
@@ -175,7 +182,8 @@ function CdsTradeTable({
175
182
  /** What an empty table says: the window and the name searched. */
176
183
  emptyMessage: string;
177
184
  }) {
178
- const columns = useMemo(() => buildTradeColumns(width), [width]);
185
+ const points = hasPointsQuotes(trades);
186
+ const columns = useMemo(() => buildTradeColumns(width, points), [points, width]);
179
187
  const renderTradeRow = useCallback((trade: CdsTrade, column: TradeColumn) => (
180
188
  renderTradeCell(trade, column, lineMaturity)
181
189
  ), [lineMaturity]);
@@ -276,7 +284,7 @@ function IssuerHeader({ issuer, line, trades, width, height, focused, loading, s
276
284
  });
277
285
  return (
278
286
  <ChartTableHeader width={width} height={height} tableRows={trades.length} figures={figures}
279
- tableColumns={buildTradeColumns(width)}
287
+ tableColumns={buildTradeColumns(width, hasPointsQuotes(trades))}
280
288
  chart={points.length >= 2 || loading || why ? {
281
289
  series,
282
290
  formatValue: (value: number) => formatQuoted(unit, value),
@@ -388,12 +396,13 @@ export function CdsPane({
388
396
  setDetailOpen(false);
389
397
  }, [issuerQuery, issuers, selectedIssuerKey]);
390
398
 
399
+ const tradePoints = hasPointsQuotes(visibleTrades);
391
400
  const cycleTradeSort = useCallback((step: 1 | -1) => {
392
401
  setTradeSort((current) => {
393
- const next = cycleSortPreference<TradeColumnId>(TRADE_SORT_COLUMN_IDS, current, step);
402
+ const next = cycleSortPreference<TradeColumnId>(tradeSortColumnIds(tradePoints), current, step);
394
403
  return { columnId: next.columnId ?? DEFAULT_TRADE_SORT.columnId, direction: next.direction };
395
404
  });
396
- }, []);
405
+ }, [tradePoints]);
397
406
  const cycleIssuerSort = useCallback((step: 1 | -1) => {
398
407
  setIssuerSort((current) => {
399
408
  const next = cycleSortPreference<IssuerColumnId>(ISSUER_SORT_COLUMN_IDS, current, step);
@@ -451,6 +460,8 @@ export function CdsPane({
451
460
  query: issuerQuery ? activity?.query ?? issuerQuery : selectedSummary?.issuer ?? null,
452
461
  issuer: issuerQuery ? activity?.issuer ?? null : selectedSummary?.issuer ?? null,
453
462
  days: activity?.days ?? CDS_TRADE_DAYS,
463
+ // A name the covered issuers do not have, once the tape has neither trades nor a line under it.
464
+ unmatched: issuerQuery && activity?.unmatched && !trades.length && history.data && !history.data.issuer ? activity.unmatched : null,
454
465
  });
455
466
 
456
467
  if (status === "loading" && !activity) {
@@ -1,6 +1,6 @@
1
1
  import type { SeriesDefinition } from "../../../series/types";
2
2
  import { CENTRAL_BANKS, centralBankNames } from "../../../utils/central-banks";
3
- import { cachedSeriesWarning } from "../shared/series-warning";
3
+ import { cachedSeriesNote } from "../shared/series-warning";
4
4
  import { loadCentralBankRates } from "./client";
5
5
  import { policySeriesAttribution } from "./model";
6
6
 
@@ -24,12 +24,11 @@ export const CENTRAL_BANK_SERIES: readonly SeriesDefinition[] = CENTRAL_BANKS.ma
24
24
  const resource = await loadCentralBankRates();
25
25
  const row = resource.payload.rows.find((entry) => entry.id === bank.id);
26
26
  if (!row) throw new Error(`${bank.place} is not on the board right now.`);
27
- const warning = cachedSeriesWarning(resource);
28
27
  // Cited as CBR cites the row: the BIS or FRED series and the bank that sets the rate.
29
28
  const attribution = policySeriesAttribution(row);
30
29
  return {
31
30
  points: row.history.map((point) => ({ date: point.date, value: point.value })),
32
- ...(warning ? { warning } : {}),
31
+ ...cachedSeriesNote(resource),
33
32
  ...(attribution ? { attribution } : {}),
34
33
  };
35
34
  },
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../../time-series/spec";
12
12
  import type { ChartSeriesSpec, ChartSpec, SeriesPeriod } from "../../../time-series/types";
13
13
  import { yearToDateBaseDay } from "../../../market-data/market/year-to-date";
14
+ import { readPriceWindow } from "../../../utils/price-window";
14
15
 
15
16
  const PRICE_CAPABILITIES = new Set(["price-chart", "intraday-price-chart", "price-comparison"]);
16
17
 
@@ -35,6 +36,13 @@ export function yearToDateWindow(spec: ChartSpec, now = Date.now()): { start: st
35
36
  return { start: starts.sort()[0] ?? `${Number(today.slice(0, 4)) - 1}-12-31`, end: today };
36
37
  }
37
38
 
39
+ /** The shortest range whose history reaches back to `from`: 5Y for a window from 2022, ALL for one from 2019. */
40
+ function rangeCovering(from: string, now = Date.now()): TimeRange {
41
+ const days = (now - Date.parse(`${from}T00:00:00Z`)) / 86_400_000;
42
+ const covering: Array<[TimeRange, number]> = [["1M", 31], ["3M", 92], ["6M", 183], ["1Y", 366], ["5Y", 5 * 366]];
43
+ return covering.find(([, span]) => days <= span)?.[0] ?? "ALL";
44
+ }
45
+
38
46
  function chartResolution(options: HeadlessPaneOptionValues): ChartResolution | null {
39
47
  return parseChartResolution(options.chartResolution ?? options.resolution);
40
48
  }
@@ -77,6 +85,14 @@ export function applyChartComposerCapabilityOptions(
77
85
  };
78
86
  }
79
87
 
88
+ // --from and --to: those dates, each leg rebased at its first close in them, as YTD is from the prior year's last.
89
+ const window = capabilityId === "price-comparison"
90
+ ? readPriceWindow(typeof options.from === "string" ? options.from : undefined, typeof options.to === "string" ? options.to : undefined)
91
+ : null;
92
+ if (window) {
93
+ next = { ...next, viewport: { ...next.viewport, range: rangeCovering(window.from), dateWindow: { start: window.from, end: window.to } } };
94
+ }
95
+
80
96
  if (capabilityId === "price-chart" && options.extendedHours === true) {
81
97
  next = { ...next, viewport: { ...next.viewport, extendedHours: true } };
82
98
  }
@@ -21,6 +21,16 @@ function normalized(instrument: InstrumentRef): InstrumentRef {
21
21
  const key = (instrument: InstrumentRef) => instrumentIdentityKey(normalized(instrument));
22
22
  const publicKey = (instrument: InstrumentRef) => publicTickerKey(instrument.symbol, instrument.exchange);
23
23
 
24
+ /**
25
+ * One listing however a series names it: the same symbol and broker contract, on the same venue
26
+ * or with the venue left out on either side (`AAPL` added beside a revenue line on `AAPL:NASDAQ`).
27
+ */
28
+ function sameListing(left: InstrumentRef, right: InstrumentRef): boolean {
29
+ const a = normalized(left), b = normalized(right);
30
+ return key({ ...a, exchange: "" }) === key({ ...b, exchange: "" })
31
+ && (!a.exchange || !b.exchange || canonicalExchange(a.exchange) === canonicalExchange(b.exchange));
32
+ }
33
+
24
34
  /** Bootstrap old charts once; persisted IDs keep ownership through comparison collisions. */
25
35
  export function resolveFollowSeriesIds(
26
36
  spec: ChartSpec,
@@ -44,8 +54,10 @@ export function resolveFollowSeriesIds(
44
54
  || (next && securities.find(series => series.source.kind === "security" && publicKey(normalized(series.source.instrument)) === publicKey(next)))
45
55
  || securities[0];
46
56
  if (!owner || owner.source.kind !== "security") return [];
47
- const ownerKey = key(owner.source.instrument);
48
- return securities.filter(series => series.source.kind === "security" && key(series.source.instrument) === ownerKey).map(series => series.id);
57
+ const ownerInstrument = owner.source.instrument;
58
+ // Every field of the followed ticker moves with it (revenue and total debt); other tickers stay.
59
+ return securities.filter(series => series.source.kind === "security" && sameListing(series.source.instrument, ownerInstrument))
60
+ .map(series => series.id);
49
61
  }
50
62
 
51
63
  /** Only the followed series move; other contracts and comparisons stay authored. */
@@ -3,6 +3,7 @@ import { isSubMinuteInterval, subMinuteDataMessage } from "../../../cli/known-ga
3
3
  import { CHART_BAR_SIZE_PRESETS, parseChartBarSize } from "../../../time-series/resolution";
4
4
  import { FINANCIAL_PERIOD_OPTION, FINANCIAL_PERIOD_COUNT_OPTION, HISTORY_RANGE_VALUES, RETURN_RANGE_VALUES } from "../shared/headless-options";
5
5
  import { DATE_OPTION_EXAMPLES } from "../../../utils/calendar-date";
6
+ import { PRICE_WINDOW_FROM_HELP, PRICE_WINDOW_TO_HELP } from "../../../utils/price-window";
6
7
 
7
8
  type PaneSchema = Pick<HeadlessPaneDefinition, "argument" | "options" | "discovery">;
8
9
 
@@ -138,6 +139,10 @@ export const paneSchemas = {
138
139
  ],
139
140
  defaultValue: "percent",
140
141
  },
142
+ // A window of dates, each leg rebased at its first close in it: --from 2019-12-31 compares since then.
143
+ { key: "from", type: "string", placeholder: "year|date", description: `${PRICE_WINDOW_FROM_HELP}; the returns are measured from its first shared close`,
144
+ example: "--from 2019-12-31", replaces: ["rangePreset"] },
145
+ { key: "to", type: "string", placeholder: "year|date", description: PRICE_WINDOW_TO_HELP },
141
146
  {
142
147
  key: "totalReturn",
143
148
  description: "Reinvest each cash distribution at its ex-date close, so the returns are total returns, not price returns.",
@@ -10,7 +10,6 @@ import { parseTickerListInput } from "../../../tickers/list";
10
10
  import { canonicalExchange, parsePublicTickerKey, publicTickerKey } from "../../../utils/exchanges";
11
11
  import { tickerInstrumentLabel } from "../../../tickers/instrument-label";
12
12
  import type { ChartSpec } from "../../../time-series/types";
13
- import { isPriceOnlyMarketFieldId } from "../../../time-series/field-catalog";
14
13
  import { ChartComposerPane, ChartComposerResearchTab } from "./pane";
15
14
  import { DataCatalogPane } from "./data-catalog-pane";
16
15
  import { listFieldSeries } from "./field-series";
@@ -484,9 +483,10 @@ const chartComposerTemplates: PaneTemplateDef[] = [
484
483
  ];
485
484
 
486
485
  /**
487
- * A price chart of one security (GP, GIP, a custom price chart of one ticker) can follow a list.
488
- * A comparison would move only its first ticker, a FRED-only chart has no ticker to follow, and
489
- * fundamental and valuation graphs (GF, GE) stay out even for one ticker.
486
+ * A chart whose securities are all one ticker can follow a list: GP, GIP, and GF or GE of one
487
+ * ticker, whatever fields it shows (revenue beside total debt). Every series of that ticker moves
488
+ * to the selected one and keeps its field; FRED and other series stay as they are. A comparison
489
+ * of several tickers would move only one of them, and a FRED-only chart has no ticker to follow.
490
490
  */
491
491
  function chartShowsOneSecurity(pane: PaneInstanceConfig): boolean {
492
492
  const spec = parseChartSpec(pane.settings?.[CHART_SPEC_SETTING_KEY]);
@@ -495,7 +495,6 @@ function chartShowsOneSecurity(pane: PaneInstanceConfig): boolean {
495
495
  const symbols = new Set<string>();
496
496
  for (const series of spec.series) {
497
497
  if (series.source.kind !== "security") continue;
498
- if (!isPriceOnlyMarketFieldId(series.source.fieldId)) return false;
499
498
  symbols.add(parsePublicTickerKey(series.source.instrument.symbol).symbol.toUpperCase());
500
499
  }
501
500
  return symbols.size === 1;
@@ -20,6 +20,7 @@ import {
20
20
  type MultiSelectRowAction,
21
21
  } from "../../../components/ui";
22
22
  import { CompositeChart } from "../../../components/chart/composite";
23
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
23
24
  import { useChartViewReset, useChartViewResetListener } from "../../../components/chart/core/view-reset";
24
25
  import { formatCompositeDatasetDate } from "../../../components/chart/composite/format";
25
26
  import type { CompositeChartLevels, CompositeLevelEdit } from "../../../components/chart/composite/levels";
@@ -1116,9 +1117,12 @@ function ChartComposerSurface({
1116
1117
  }
1117
1118
  return newest > end ? formatCompositeDatasetDate(new Date(newest), end) : null;
1118
1119
  }, [activeRuntimeViewport, plottedSeries, spec, viewport]);
1120
+ // A series a failed refresh left on a saved copy says when that copy was saved, as every pane does.
1121
+ const savedAt = resolution.savedAt;
1119
1122
  usePaneFooter(footerId, () => ({
1120
1123
  info: [
1121
1124
  ...(delistingKey ? [{ id: "delisted", parts: [{ text: delistingKey, tone: "warning" as const }] }] : []),
1125
+ ...savedCopyFooterInfo([savedAt]),
1122
1126
  ...(loading
1123
1127
  ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }]
1124
1128
  : pickingAnchor
@@ -1136,7 +1140,7 @@ function ChartComposerSurface({
1136
1140
  { id: "resolution", key: "b", label: "ars", title: "Bar Size", onPress: footerResolution },
1137
1141
  ...(anchorable ? [{ id: "vwap-anchor", key: "v", label: "wap anchor", title: "Anchor VWAP", onPress: footerAnchor }] : []),
1138
1142
  ],
1139
- }), [delistingKey, loading, pickingAnchor, newestBeyondView, footerSeries, openIndicators, indicatorsDisabled, openFormulas, formulasDisabled, footerResolution, anchorable, footerAnchor, trade]);
1143
+ }), [delistingKey, savedAt, loading, pickingAnchor, newestBeyondView, footerSeries, openIndicators, indicatorsDisabled, openFormulas, formulasDisabled, footerResolution, anchorable, footerAnchor, trade]);
1140
1144
 
1141
1145
  // A fixed window (a GIP session) highlights no range, so the bar names it.
1142
1146
  const dateWindowLabel = useMemo(() => {
@@ -4,6 +4,7 @@ import { cleanFloat32Price } from "../../../cli/history-rows";
4
4
  import { exportNumber } from "../../../cli/report-tables";
5
5
  import { publicTickerKey } from "../../../utils/exchanges";
6
6
  import { formatUtcTime } from "../../../utils/utc-time";
7
+ import { pricePointWicks } from "../../../utils/price-history-integrity";
7
8
 
8
9
  interface SessionExtreme {
9
10
  price: number;
@@ -26,8 +27,10 @@ function sessionRange(points: readonly PricePoint[]): SessionRange | null {
26
27
  for (const point of points) {
27
28
  const at = point.date;
28
29
  if (!(at instanceof Date) || !Number.isFinite(at.getTime())) continue;
29
- const pointLow = finitePositive(point.low) ?? finitePositive(point.close);
30
- const pointHigh = finitePositive(point.high) ?? finitePositive(point.close);
30
+ // A wick too far from the bar's body to have traded is not a price the bars traded.
31
+ const wicks = pricePointWicks(point);
32
+ const pointLow = finitePositive(wicks.low) ?? finitePositive(point.close);
33
+ const pointHigh = finitePositive(wicks.high) ?? finitePositive(point.close);
31
34
  if (pointLow != null && (!low || pointLow < low.price)) low = { price: pointLow, at };
32
35
  if (pointHigh != null && (!high || pointHigh > high.price)) high = { price: pointHigh, at };
33
36
  }
@@ -74,6 +74,7 @@ import { ChatControllerMessageLoading } from "./message-loading";
74
74
  import { ChatMentionCandidates } from "./mention-candidates";
75
75
  import { ChatControllerStorage } from "./storage";
76
76
  import { listUnreadInboxItems } from "../unread-inbox";
77
+ import { ChatStatusPreview } from "../status-preview";
77
78
  import type { ChatConversationList } from "../conversations";
78
79
  import {
79
80
  conversationActivityAt,
@@ -118,6 +119,8 @@ export class ChatController {
118
119
  );
119
120
  private notifyFn: (notification: AppNotificationRequest) => AppNotificationDelivery | void = () => {};
120
121
  private openMessageFn: ((channelId: string, messageId: string) => void) | undefined;
122
+ /** A DM, mention or reply that shows in the status bar on arrival; the status bar widget draws it. */
123
+ readonly statusPreview = new ChatStatusPreview();
121
124
  private readonly alerts = new ChatNotificationAlerts({
122
125
  getAppActive: () => this.appActive,
123
126
  ensureChannelState: (channelId) => this.ensureChannelState(channelId),
@@ -125,6 +128,7 @@ export class ChatController {
125
128
  getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
126
129
  getNotify: () => this.notifyFn,
127
130
  getOpenMessage: () => this.openMessageFn,
131
+ statusPreview: this.statusPreview,
128
132
  });
129
133
 
130
134
  private readonly storage = new ChatControllerStorage({
@@ -271,7 +275,7 @@ export class ChatController {
271
275
  * Keeps who sent each channel's unread messages (`unreadPeople` in the
272
276
  * snapshot) current until the returned stop is called, reading what the
273
277
  * device does not hold. Nothing is read, and the snapshot leaves it empty,
274
- * unless something tracks it: only the app badge needs it.
278
+ * unless something tracks it: the app badge and the status bar widget.
275
279
  */
276
280
  trackUnreadPeople(): () => void {
277
281
  return this.unreadPeople.track();
@@ -5,7 +5,9 @@ import type {
5
5
  import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification } from "../../../../api-client";
6
6
  import type { ChannelRuntimeState, MergeMessagesOptions } from "./state";
7
7
  import { formatChatPaneTitle } from "../channel-labels";
8
+ import type { ChatStatusPreview } from "../status-preview";
8
9
  import {
10
+ chatMessageLine,
9
11
  formatChannelToast,
10
12
  formatMentionToast,
11
13
  formatReplyToast,
@@ -28,6 +30,8 @@ interface ChatNotificationAlertsOptions {
28
30
  getChannel: (channelId: string) => ChatChannel | undefined;
29
31
  getNotify: () => Notify;
30
32
  getOpenMessage: () => ((channelId: string, messageId: string) => void) | undefined;
33
+ /** Where a message for you shows on arrival instead of a toast, while the status bar has room. */
34
+ statusPreview: ChatStatusPreview;
31
35
  }
32
36
 
33
37
  /** Whether this window holds the message as read: its read marker is at or after it. */
@@ -53,9 +57,9 @@ function acknowledge(notification: ChatNotification, seen: boolean): void {
53
57
  }
54
58
 
55
59
  /**
56
- * Decides, once per message, whether a chat notification becomes a toast or
57
- * a desktop banner. A message already read here or on another device stays
58
- * quiet, whether it arrives live or again on reconnect.
60
+ * Decides, once per message, whether a chat notification shows in the status
61
+ * bar, as a toast or as a desktop banner. A message already read here or on
62
+ * another device stays quiet, whether it arrives live or again on reconnect.
59
63
  */
60
64
  export class ChatNotificationAlerts {
61
65
  private readonly notifiedMessageIds = new Set<string>();
@@ -94,6 +98,7 @@ export class ChatNotificationAlerts {
94
98
  for (const timer of this.waiting.values()) clearTimeout(timer);
95
99
  this.waiting.clear();
96
100
  this.notifiedMessageIds.clear();
101
+ this.options.statusPreview.dismiss();
97
102
  }
98
103
 
99
104
  /** Read here, or open in front of the reader: nothing to raise, and the server hears it was seen. */
@@ -124,8 +129,33 @@ export class ChatNotificationAlerts {
124
129
  this.present(notification);
125
130
  }
126
131
 
132
+ /**
133
+ * A DM or group message, a mention or a reply, while the app is in front:
134
+ * the status bar shows it in place of the toast when it has room, and the
135
+ * server hears it was seen, as for a toast. Channel chatter never shows there.
136
+ */
137
+ private presentInStatusBar(notification: ChatNotification, channel: ChatChannel | undefined): boolean {
138
+ const direct = channel?.kind === "direct" || channel?.kind === "group";
139
+ if (!this.options.getAppActive() || (notification.type === "channel" && !direct)) return false;
140
+ const { message } = notification;
141
+ const sentAt = Date.parse(message.createdAt);
142
+ const shown = this.options.statusPreview.offer({
143
+ channelId: notification.channelId,
144
+ messageId: notification.messageId,
145
+ senderName: message.user.username?.trim() || message.user.displayName,
146
+ direct,
147
+ text: chatMessageLine(message),
148
+ at: Number.isFinite(sentAt) ? sentAt : Date.now(),
149
+ });
150
+ if (!shown) return false;
151
+ this.notifiedMessageIds.add(notification.messageId);
152
+ acknowledge(notification, true);
153
+ return true;
154
+ }
155
+
127
156
  private present(notification: ChatNotification): void {
128
157
  const channel = this.options.getChannel(notification.channelId);
158
+ if (this.presentInStatusBar(notification, channel)) return;
129
159
  const body = notification.type === "reply"
130
160
  ? formatReplyToast(notification.message)
131
161
  : notification.type === "mention"