gloomberb 0.18.1 → 0.18.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 (307) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/commodity-balances.ts +6 -0
  3. package/src/api-client/company-kpis.ts +9 -0
  4. package/src/api-client/crypto-markets.ts +9 -0
  5. package/src/api-client/data.ts +6 -5
  6. package/src/api-client/equity-screener.ts +2 -0
  7. package/src/api-client/types.ts +5 -0
  8. package/src/app/pane-runtime/plugin-bindings.ts +3 -1
  9. package/src/app/pane-runtime/ticker-open-runtime.ts +17 -5
  10. package/src/app/runtime/plugin-bindings.ts +6 -2
  11. package/src/brokers/logos.ts +66 -0
  12. package/src/brokers/profile-form.ts +3 -0
  13. package/src/brokers/remote-broker-adapter.ts +13 -3
  14. package/src/capabilities/index.ts +1 -1
  15. package/src/capabilities/service-capabilities.ts +7 -1
  16. package/src/cli/base-converter.ts +60 -18
  17. package/src/cli/commands/market.ts +281 -154
  18. package/src/cli/commands/news-search.ts +35 -8
  19. package/src/cli/commands/system.ts +35 -1
  20. package/src/cli/commands/ticker.ts +100 -50
  21. package/src/cli/currency-hints.ts +27 -0
  22. package/src/cli/fx-pair.ts +41 -4
  23. package/src/cli/glossary.ts +57 -4
  24. package/src/cli/helpers.ts +36 -3
  25. package/src/cli/index.ts +4 -1
  26. package/src/cli/known-gaps.ts +37 -1
  27. package/src/cli/listing-arg.ts +37 -2
  28. package/src/cli/pane-functions/catalog.ts +3 -1
  29. package/src/cli/pane-functions/discovery.ts +15 -1
  30. package/src/cli/pane-functions/dom.ts +9 -4
  31. package/src/cli/pane-functions/freshness.ts +4 -4
  32. package/src/cli/pane-functions/function-help.ts +27 -3
  33. package/src/cli/pane-functions/headless.ts +6 -5
  34. package/src/cli/pane-functions/index.ts +3 -2
  35. package/src/cli/pane-functions/lock-note.ts +7 -4
  36. package/src/cli/pane-functions/resolver.ts +2 -1
  37. package/src/cli/quote-answer.ts +179 -0
  38. package/src/cli/series-search.ts +2 -1
  39. package/src/cli/types.ts +4 -0
  40. package/src/components/chart/composite/composite-chart.tsx +11 -2
  41. package/src/components/chart/composite/format.ts +13 -1
  42. package/src/components/chart/composite/level-labels.ts +30 -0
  43. package/src/components/chart/composite/levels.ts +17 -6
  44. package/src/components/chart/composite/panel-surface.tsx +58 -8
  45. package/src/components/command-bar/routes/ticker-search/actions.ts +6 -0
  46. package/src/components/index.ts +2 -0
  47. package/src/components/layout/pane/content.tsx +16 -14
  48. package/src/components/layout/pane/footer/index.tsx +22 -3
  49. package/src/components/layout/pane/footer/model.ts +99 -4
  50. package/src/components/layout/pane/header-tabs.tsx +15 -1
  51. package/src/components/onboarding/onboarding-frame.tsx +9 -11
  52. package/src/components/onboarding/use-onboarding-broker-form.ts +5 -2
  53. package/src/components/onboarding/wizard-model.ts +10 -3
  54. package/src/components/ui/button.tsx +11 -4
  55. package/src/components/ui/choice-dialog.tsx +8 -5
  56. package/src/components/ui/display.tsx +11 -6
  57. package/src/components/ui/fields.tsx +100 -20
  58. package/src/components/ui/icon.tsx +6 -1
  59. package/src/components/ui/list-row-lines.tsx +87 -0
  60. package/src/components/ui/list-view.tsx +14 -0
  61. package/src/components/ui/logo-mark.tsx +64 -0
  62. package/src/components/ui/number-step.ts +54 -0
  63. package/src/components/ui/select-button.tsx +5 -4
  64. package/src/components/ui/select-field.tsx +3 -3
  65. package/src/components/ui/status.tsx +17 -3
  66. package/src/components/ui/tabs.tsx +9 -1
  67. package/src/components/ui/toggle.tsx +17 -3
  68. package/src/core/state/app/instrument.ts +8 -3
  69. package/src/data/config/store/normalize.ts +4 -1
  70. package/src/data/plugin-cache.ts +32 -6
  71. package/src/docs/snapshot.json +5 -5
  72. package/src/i18n/es.ts +9 -3
  73. package/src/i18n/ja.ts +9 -3
  74. package/src/i18n/ko.ts +9 -3
  75. package/src/i18n/zh-cn.ts +10 -4
  76. package/src/i18n/zh-tw.ts +10 -4
  77. package/src/market-data/commodity-names.ts +80 -0
  78. package/src/market-data/coordinator/auxiliary.ts +3 -1
  79. package/src/market-data/coordinator/chart.ts +3 -1
  80. package/src/market-data/futures-board.ts +38 -1
  81. package/src/market-data/futures-contract.ts +91 -0
  82. package/src/market-data/fx-closes.ts +78 -1
  83. package/src/market-data/market/change-basis.ts +219 -0
  84. package/src/market-data/market/format.ts +28 -12
  85. package/src/market-data/market/freshness.ts +38 -0
  86. package/src/market-data/market/status.ts +55 -9
  87. package/src/market-data/market/year-to-date.ts +46 -0
  88. package/src/market-data/quotes/contributions.ts +12 -0
  89. package/src/market-data/quotes/delisting.ts +34 -0
  90. package/src/market-data/quotes/live-valuation.ts +23 -0
  91. package/src/market-data/quotes/resolution.ts +8 -2
  92. package/src/market-data/tax-equivalent.ts +10 -2
  93. package/src/notifications/app-notifier.ts +2 -2
  94. package/src/notifications/notification-log.ts +8 -6
  95. package/src/plugins/builtin/alerts/cli.ts +26 -18
  96. package/src/plugins/builtin/alerts/headless.ts +1 -1
  97. package/src/plugins/builtin/alerts/index.tsx +13 -4
  98. package/src/plugins/builtin/attention/trending.tsx +8 -6
  99. package/src/plugins/builtin/backtest/client.ts +5 -2
  100. package/src/plugins/builtin/backtest/window.ts +16 -3
  101. package/src/plugins/builtin/broker-manager/add-flow.tsx +40 -8
  102. package/src/plugins/builtin/broker-manager/detail.tsx +26 -1
  103. package/src/plugins/builtin/broker-manager/index.tsx +20 -9
  104. package/src/plugins/builtin/broker-manager/model.ts +5 -0
  105. package/src/plugins/builtin/broker-manager/pane-actions.tsx +8 -1
  106. package/src/plugins/builtin/broker-manager/table.ts +5 -1
  107. package/src/plugins/builtin/broker-trading/account-picker.tsx +91 -0
  108. package/src/plugins/builtin/broker-trading/chart-levels.ts +54 -0
  109. package/src/plugins/builtin/broker-trading/command.ts +103 -0
  110. package/src/plugins/builtin/broker-trading/controller.ts +688 -0
  111. package/src/plugins/builtin/broker-trading/dock.tsx +182 -0
  112. package/src/plugins/builtin/broker-trading/headless.ts +76 -0
  113. package/src/plugins/builtin/broker-trading/index.tsx +77 -0
  114. package/src/plugins/builtin/broker-trading/order-feed.ts +383 -0
  115. package/src/plugins/builtin/broker-trading/orders-data.ts +133 -0
  116. package/src/plugins/builtin/broker-trading/orders.tsx +437 -0
  117. package/src/plugins/builtin/broker-trading/overview-orders.tsx +121 -0
  118. package/src/plugins/builtin/broker-trading/pane-model.ts +207 -0
  119. package/src/plugins/builtin/broker-trading/pane.tsx +505 -0
  120. package/src/plugins/builtin/broker-trading/strategy.ts +153 -0
  121. package/src/plugins/builtin/broker-trading/ticket-parts.tsx +117 -0
  122. package/src/plugins/builtin/broker-trading/ticket.tsx +573 -0
  123. package/src/plugins/builtin/buildout/data-runtime.ts +1 -1
  124. package/src/plugins/builtin/buildout/format.ts +1 -0
  125. package/src/plugins/builtin/buildout/model/index.ts +16 -7
  126. package/src/plugins/builtin/buildout/pane/index.tsx +3 -2
  127. package/src/plugins/builtin/buildout/pane/status.tsx +3 -3
  128. package/src/plugins/builtin/chart-composer/cli-options.ts +19 -0
  129. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +2 -1
  130. package/src/plugins/builtin/chart-composer/headless-schema.ts +3 -3
  131. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  132. package/src/plugins/builtin/chart-composer/option-range.ts +39 -0
  133. package/src/plugins/builtin/chart-composer/pane.tsx +31 -11
  134. package/src/plugins/builtin/chart-composer/presets.ts +2 -2
  135. package/src/plugins/builtin/chart-composer/series-catalog.ts +4 -2
  136. package/src/plugins/builtin/chart-composer/shortcuts.ts +3 -3
  137. package/src/plugins/builtin/chart-composer/viewport-labels.ts +5 -2
  138. package/src/plugins/builtin/chat/index.tsx +1 -1
  139. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  140. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +6 -1
  141. package/src/plugins/builtin/chat/unread-inbox.ts +1 -1
  142. package/src/plugins/builtin/commodity-balances/client.ts +6 -4
  143. package/src/plugins/builtin/commodity-balances/headless.ts +41 -7
  144. package/src/plugins/builtin/commodity-balances/index.tsx +19 -4
  145. package/src/plugins/builtin/commodity-balances/model.ts +22 -0
  146. package/src/plugins/builtin/commodity-balances/pane.tsx +36 -5
  147. package/src/plugins/builtin/company-kpis/client.ts +3 -2
  148. package/src/plugins/builtin/company-kpis/headless.ts +5 -7
  149. package/src/plugins/builtin/company-kpis/model.ts +85 -2
  150. package/src/plugins/builtin/company-kpis/pane.tsx +26 -12
  151. package/src/plugins/builtin/composite-plugins.ts +3 -2
  152. package/src/plugins/builtin/cot/headless.ts +1 -1
  153. package/src/plugins/builtin/cot/index.tsx +1 -1
  154. package/src/plugins/builtin/cot/model.ts +35 -12
  155. package/src/plugins/builtin/credit-boards/cdx-pane.tsx +2 -1
  156. package/src/plugins/builtin/credit-boards/sovr-pane.tsx +2 -1
  157. package/src/plugins/builtin/credit-conditions/index.tsx +2 -1
  158. package/src/plugins/builtin/crypto-board/client.ts +33 -2
  159. package/src/plugins/builtin/crypto-board/headless.ts +5 -2
  160. package/src/plugins/builtin/crypto-board/model.ts +28 -8
  161. package/src/plugins/builtin/dividend-yield/headless.ts +30 -8
  162. package/src/plugins/builtin/dividend-yield/provider-client.ts +3 -1
  163. package/src/plugins/builtin/earnings-calls/headless.ts +4 -4
  164. package/src/plugins/builtin/earnings-calls/lock-note.ts +12 -3
  165. package/src/plugins/builtin/econ/calendar-model.ts +35 -0
  166. package/src/plugins/builtin/econ/index.tsx +31 -4
  167. package/src/plugins/builtin/econ/print-watch.ts +279 -0
  168. package/src/plugins/builtin/equity-screener/headless.ts +20 -5
  169. package/src/plugins/builtin/equity-screener/model.ts +40 -2
  170. package/src/plugins/builtin/equity-screener/pane.tsx +24 -21
  171. package/src/plugins/builtin/estimate-revisions/headless.ts +3 -2
  172. package/src/plugins/builtin/estimate-revisions/lock-note.ts +29 -7
  173. package/src/plugins/builtin/exposure/client.ts +4 -1
  174. package/src/plugins/builtin/exposure/editor.tsx +3 -3
  175. package/src/plugins/builtin/exposure/evidence.ts +4 -1
  176. package/src/plugins/builtin/exposure/holdings.ts +3 -1
  177. package/src/plugins/builtin/exposure/pane.tsx +37 -16
  178. package/src/plugins/builtin/filing-events/index.tsx +9 -3
  179. package/src/plugins/builtin/futures/table.tsx +6 -3
  180. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  181. package/src/plugins/builtin/insider/base-values.ts +72 -0
  182. package/src/plugins/builtin/insider/headless.ts +29 -4
  183. package/src/plugins/builtin/jobs/headless.ts +2 -2
  184. package/src/plugins/builtin/jobs/lock-note.ts +14 -3
  185. package/src/plugins/builtin/market-halts/model.ts +31 -6
  186. package/src/plugins/builtin/market-halts/pane.tsx +3 -0
  187. package/src/plugins/builtin/market-valuation/pane.tsx +2 -1
  188. package/src/plugins/builtin/members/pane.tsx +8 -3
  189. package/src/plugins/builtin/news/wire/feed-config.ts +29 -12
  190. package/src/plugins/builtin/news/wire/index.tsx +7 -2
  191. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +11 -3
  192. package/src/plugins/builtin/news/wire/rss/cli.ts +3 -2
  193. package/src/plugins/builtin/news/wire/rss/source.ts +13 -4
  194. package/src/plugins/builtin/options/footer.ts +25 -20
  195. package/src/plugins/builtin/options/live-quotes.ts +33 -18
  196. package/src/plugins/builtin/options/view.tsx +77 -22
  197. package/src/plugins/builtin/options-calculator/pane.tsx +4 -2
  198. package/src/plugins/builtin/options-scenario/pane.tsx +41 -6
  199. package/src/plugins/builtin/portfolio-list/account-metrics.ts +3 -0
  200. package/src/plugins/builtin/portfolio-list/cli/render.ts +34 -10
  201. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  202. package/src/plugins/builtin/portfolio-list/pane/data.ts +23 -1
  203. package/src/plugins/builtin/portfolio-list/pane/index.tsx +33 -14
  204. package/src/plugins/builtin/portfolio-list/settings.ts +20 -2
  205. package/src/plugins/builtin/portfolio-list/summary/index.tsx +12 -0
  206. package/src/plugins/builtin/portfolio-list/summary/totals.ts +24 -3
  207. package/src/plugins/builtin/primary-dealers/client.ts +137 -62
  208. package/src/plugins/builtin/primary-dealers/headless.ts +30 -5
  209. package/src/plugins/builtin/primary-dealers/index.tsx +3 -2
  210. package/src/plugins/builtin/primary-dealers/model.ts +179 -129
  211. package/src/plugins/builtin/primary-dealers/pane.tsx +135 -15
  212. package/src/plugins/builtin/research/analyst-model.ts +5 -1
  213. package/src/plugins/builtin/risk-factors/index.tsx +12 -3
  214. package/src/plugins/builtin/scanner/feed.ts +6 -1
  215. package/src/plugins/builtin/scanner/headless.ts +1 -1
  216. package/src/plugins/builtin/scanner/lock-note.ts +3 -2
  217. package/src/plugins/builtin/series-inventory.ts +1 -0
  218. package/src/plugins/builtin/shared/cloud-denied.ts +7 -6
  219. package/src/plugins/builtin/shared/cloud-upgrade.ts +10 -11
  220. package/src/plugins/builtin/shared/free-routes.ts +24 -0
  221. package/src/plugins/builtin/shared/headless-options.ts +11 -0
  222. package/src/plugins/builtin/shared/pro-lock-note.ts +89 -7
  223. package/src/plugins/builtin/shared/research-trading.tsx +62 -0
  224. package/src/plugins/builtin/social-mentions/headless.ts +1 -1
  225. package/src/plugins/builtin/social-mentions/lock-note.ts +1 -1
  226. package/src/plugins/builtin/themes/headless.ts +8 -2
  227. package/src/plugins/builtin/themes/model.ts +43 -10
  228. package/src/plugins/builtin/themes/pane.tsx +8 -6
  229. package/src/plugins/builtin/ticker-detail/overview/model.ts +44 -10
  230. package/src/plugins/builtin/ticker-detail/overview/option-contract.ts +64 -0
  231. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +82 -21
  232. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -14
  233. package/src/plugins/builtin/ticker-detail/quote-footer.ts +5 -1
  234. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +37 -8
  235. package/src/plugins/builtin/time-sales/pane.tsx +2 -2
  236. package/src/plugins/builtin/trace-bonds/client.ts +14 -3
  237. package/src/plugins/builtin/trace-bonds/headless.ts +1 -1
  238. package/src/plugins/builtin/vol-surface/pane.tsx +2 -2
  239. package/src/plugins/builtin-editorial.ts +1 -1
  240. package/src/plugins/compat.ts +3 -0
  241. package/src/plugins/external-runtime.ts +5 -0
  242. package/src/plugins/loader.ts +6 -0
  243. package/src/plugins/plugin-icon.ts +61 -0
  244. package/src/plugins/registry/host-actions.ts +2 -1
  245. package/src/plugins/registry/index.ts +9 -1
  246. package/src/plugins/runtime/context.tsx +5 -0
  247. package/src/plugins/runtime/index.tsx +3 -0
  248. package/src/portfolio/column-values.ts +13 -4
  249. package/src/portfolio/cost-fx.ts +5 -28
  250. package/src/public/__snapshots__/public-api.test.ts.snap +5 -0
  251. package/src/public/broker.ts +8 -0
  252. package/src/public/market-data.ts +5 -0
  253. package/src/renderers/dom/desktop/control-styles.ts +7 -1
  254. package/src/renderers/dom/desktop/controls.tsx +39 -7
  255. package/src/renderers/dom/desktop/icons.tsx +14 -0
  256. package/src/renderers/dom/desktop/list-view.tsx +51 -19
  257. package/src/renderers/dom/desktop/menu.tsx +10 -1
  258. package/src/renderers/dom/desktop/select-field.tsx +8 -6
  259. package/src/renderers/dom/host/style.ts +6 -1
  260. package/src/renderers/dom/host/tabs.tsx +6 -0
  261. package/src/renderers/dom/styles.css +12 -5
  262. package/src/renderers/electrobun/bun/core-capabilities.ts +12 -0
  263. package/src/renderers/electrobun/bun/external-plugins.ts +3 -0
  264. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  265. package/src/renderers/electrobun/view/broker-remote-client.ts +16 -0
  266. package/src/renderers/electrobun/view/external-plugins.ts +1 -0
  267. package/src/series/validate.ts +2 -2
  268. package/src/sources/gloomberb-cloud/index.ts +50 -17
  269. package/src/sources/gloomberb-cloud/normalizers.ts +4 -0
  270. package/src/sources/history-result.ts +3 -0
  271. package/src/sources/provider-errors.ts +3 -1
  272. package/src/sources/provider-router/history.ts +49 -6
  273. package/src/sources/provider-router/index.ts +2 -1
  274. package/src/state/app/activity.ts +13 -0
  275. package/src/sync/controller.ts +4 -0
  276. package/src/sync/core-contributors.ts +62 -11
  277. package/src/sync/local-only.ts +121 -0
  278. package/src/sync/react.ts +6 -0
  279. package/src/sync/types.ts +3 -0
  280. package/src/tickers/listing-fate.ts +27 -8
  281. package/src/tickers/search/index.ts +24 -3
  282. package/src/time-series/price-comparison.ts +35 -0
  283. package/src/time-series/resolve.ts +52 -11
  284. package/src/time-series/spec.ts +1 -1
  285. package/src/time-series/types.ts +5 -0
  286. package/src/types/broker.ts +56 -1
  287. package/src/types/config.ts +6 -0
  288. package/src/types/data-provider.ts +5 -1
  289. package/src/types/financials.ts +23 -0
  290. package/src/types/headless.ts +12 -2
  291. package/src/types/instrument.ts +4 -0
  292. package/src/types/plugin.ts +24 -6
  293. package/src/types/price-history.ts +6 -1
  294. package/src/types/trading.ts +67 -0
  295. package/src/ui/host.tsx +4 -0
  296. package/src/utils/base-currency.ts +108 -0
  297. package/src/utils/currency-names.ts +128 -0
  298. package/src/utils/currency-pair.ts +2 -1
  299. package/src/utils/currency-units.ts +32 -1
  300. package/src/utils/dom-screenshot.ts +22 -9
  301. package/src/utils/exchange-notation.generated.ts +360 -0
  302. package/src/utils/exchanges.ts +15 -322
  303. package/src/utils/format.ts +31 -1
  304. package/src/utils/instrument-identity.ts +38 -1
  305. package/src/utils/options.ts +46 -1
  306. package/src/utils/price-history.ts +4 -0
  307. package/src/utils/reported-money.ts +8 -4
@@ -1,4 +1,6 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
+ import type { PaneLock } from "../../../types/plugin";
3
+ import { filesForm } from "../shared/free-routes";
2
4
  import { proLockNote } from "../shared/pro-lock-note";
3
5
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
4
6
  import {
@@ -18,14 +20,21 @@ const description =
18
20
  * RISK is Pro whole (`useProFeatureWall`). The 10-Ks it reads are in the SEC
19
21
  * filing list, which needs no account: `filings` lists them with a link to
20
22
  * each, where Item 1A is the risk factors as written, without RISK's grouping
21
- * or its changes since the year before.
23
+ * or its changes since the year before. A foreign issuer files a 20-F instead
24
+ * (ASML), whose Item 3.D holds them.
22
25
  */
23
- function riskFactorsLockNote(lock: { gate: "sign-in" | "pro"; symbol: string | null }): string {
26
+ function riskFactorsLockNote(lock: PaneLock): Promise<string> {
24
27
  return proLockNote(lock, {
25
28
  token: "RISK",
26
29
  does: "it groups a 10-K's risk factors by theme and shows what was added, dropped or reworded since the year before",
27
30
  shows: "it",
28
- free: (ticker, company) => `gloomberb filings ${ticker} --form 10-K lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 1A holds the risk factors as written.`,
31
+ free: [{
32
+ text: (ticker, company) => `gloomberb filings ${ticker} --form 10-K lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 1A holds the risk factors as written.`,
33
+ answers: (symbol, context) => filesForm(symbol, "10-K", context),
34
+ }, {
35
+ text: (ticker, company) => `gloomberb filings ${ticker} --form 20-F lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 3.D holds the risk factors as written.`,
36
+ answers: (symbol, context) => filesForm(symbol, "20-F", context),
37
+ }],
29
38
  });
30
39
  }
31
40
 
@@ -12,6 +12,7 @@ import { usePaneStatusFooter } from "../../../components/layout/pane/status-foot
12
12
  import { CLOUD_QUOTE_DELAY_MINUTES } from "../../../api-client/plan-access";
13
13
  import { useAppVisible } from "../../../state/app/activity";
14
14
  import { ScannerClientContext } from "../shared/scanner-capture";
15
+ import { delayedFooterSegment } from "../../../components/layout/pane/footer/model";
15
16
 
16
17
  export interface ScannerFeedState<T extends ScannerPayload> {
17
18
  payload: T | null;
@@ -102,7 +103,11 @@ export function useScannerStatusFooter(
102
103
  const info = useMemo<PaneFooterSegment[]>(
103
104
  () => {
104
105
  const base = segment ? [segment, ...status]
105
- : delayMinutes != null ? [{ id: "scanner-delay", parts: [{ text: tf("{delay} delayed", { delay: tf("{count}m", { count: delayMinutes }) }), tone: "muted" as const }] }, ...status]
106
+ : delayMinutes != null ? [delayedFooterSegment({
107
+ id: "scanner-delay",
108
+ detail: tf("{delay} delayed", { delay: tf("{count}m", { count: delayMinutes }) }),
109
+ delay: tf("{count}m", { count: delayMinutes }),
110
+ }), ...status]
106
111
  : status;
107
112
  return extra ? [...base, extra] : base;
108
113
  },
@@ -236,7 +236,7 @@ function createFlowHeadless(
236
236
  describe: "Options Flow",
237
237
  async load(args, ctx) {
238
238
  const payload = await dependencies.load(args, ctx.apiClient, ctx.signal)
239
- .catch((error: unknown) => { throw explainRefusal(error, flowLockNote, { symbol: null }); });
239
+ .catch(async (error: unknown) => { throw await explainRefusal(error, flowLockNote, { symbol: null }); });
240
240
  return projectFlowSnapshot(payload, {
241
241
  ...DEFAULT_FLOW_FILTERS,
242
242
  ...(args.options as Partial<FlowFilters>),
@@ -5,13 +5,14 @@ import { proLockNote } from "../shared/pro-lock-note";
5
5
  * FLOW is Pro whole: the one scanner with no delayed tier to fall back to.
6
6
  * The options chain needs no account and lists each contract's day volume and
7
7
  * open interest, the raw counts behind a sweep or a block, without the prints.
8
+ * FLOW takes no ticker, so the route names none to check.
8
9
  */
9
- export function flowLockNote(lock: PaneLock): string {
10
+ export function flowLockNote(lock: PaneLock): Promise<string> {
10
11
  return proLockNote(lock, {
11
12
  token: "FLOW",
12
13
  argument: "none",
13
14
  does: "it streams real-time sweeps, blocks and large premium prints across US options exchanges",
14
15
  shows: "it",
15
- free: (ticker, company) => `gloomberb options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.`,
16
+ free: [{ text: (ticker, company) => `gloomberb options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.` }],
16
17
  });
17
18
  }
@@ -62,6 +62,7 @@ export const SERIES_INVENTORY: readonly SeriesInventoryEntry[] = [
62
62
  { module: "crypto-board", chart: "Price sparklines for the large coins", series: ["crypto:*"] },
63
63
  { module: "perps", chart: "Perpetual funding, open interest, premium and long/short account share", series: ["perps:*"] },
64
64
  { module: "perps", chart: "Perpetual mark price", notInCatalog: "not-yet", reason: "The pair's price is in the catalog; the perpetual's own mark price could be a crypto series." },
65
+ { module: "primary-dealers", chart: "A primary dealer position's weekly net level, the sum of the release's series behind the row", notInCatalog: "not-yet", reason: "Weekly and dated, one series per row of the board; the pane reads each only when its row is selected, and none is registered for the composer yet." },
65
66
  { module: "short-interest", chart: "A ticker's short interest and days to cover", series: ["short-interest:*"] },
66
67
  { module: "short-volume", chart: "A ticker's daily short volume ratio", series: ["short-volume:*"] },
67
68
  { module: "social-mentions", chart: "A ticker's daily posts", series: ["social-mentions:*"] },
@@ -34,13 +34,14 @@ export function refusedLock(error: unknown): Pick<PaneLock, "gate" | "verifyEmai
34
34
  * What a headless read throws when the server refused it behind a wall: the
35
35
  * pane's lock note on one line, so every consumer of the read says what is
36
36
  * locked and how to unlock it, with the refusal as its cause for `fn`, which
37
- * prints the whole note. Any other failure is returned as it is.
37
+ * prints the whole note. Any other failure is returned as it is. The read's
38
+ * context lets the note check the free route it names.
38
39
  */
39
- export function explainRefusal(
40
+ export async function explainRefusal(
40
41
  error: unknown,
41
- note: (lock: PaneLock) => string,
42
- lock: Pick<PaneLock, "symbol" | "token">,
43
- ): unknown {
42
+ note: (lock: PaneLock) => string | Promise<string>,
43
+ lock: Pick<PaneLock, "symbol" | "token" | "context">,
44
+ ): Promise<unknown> {
44
45
  const refused = refusedLock(error);
45
- return refused ? new Error(lockNoteReason(note({ ...refused, ...lock })), { cause: error }) : error;
46
+ return refused ? new Error(lockNoteReason(await note({ ...refused, ...lock })), { cause: error }) : error;
46
47
  }
@@ -3,6 +3,7 @@ import { apiClient } from "../../../api-client";
3
3
  import { recordResearchActivity, researchUpgradeUrl } from "../../../api-client/research-activity";
4
4
  import { getCurrentPluginTarget } from "../../current-target";
5
5
  import type { PaneFooterSegment, PaneHint } from "../../../components";
6
+ import { delayedFooterSegment } from "../../../components/layout/pane/footer/model";
6
7
  import { t, tf } from "../../../i18n";
7
8
  import { useAppLanguage } from "../../../i18n/react";
8
9
  import { useRendererHost } from "../../../ui";
@@ -303,19 +304,17 @@ export function useCloudAccessFooter({
303
304
  };
304
305
  }
305
306
  if (!showUpgrade) return null;
306
- return {
307
+ // The footer's clock, pressed for the upgrade. With a key the pitch is the
308
+ // upgrade hint, so the clock says only what is true of the data; without
309
+ // one its tooltip carries the pitch too.
310
+ return delayedFooterSegment({
307
311
  id: segmentId,
312
+ detail: shortcutScope
313
+ ? tf("{delay} delayed", { delay: delayLabel })
314
+ : tf("{delay} delayed · try Pro live", { delay: delayLabel }),
315
+ delay: delayLabel,
308
316
  onPress: openUpgrade,
309
- parts: [{
310
- // With a key the pitch is the upgrade hint, so the status says only
311
- // what is true of the data. Without one there is nowhere else for it
312
- // to go.
313
- text: shortcutScope
314
- ? tf("{delay} delayed", { delay: delayLabel })
315
- : tf("{delay} delayed · try Pro live", { delay: delayLabel }),
316
- tone: "warning",
317
- }],
318
- };
317
+ });
319
318
  }, [
320
319
  access.trialDaysLeft,
321
320
  delayLabel,
@@ -0,0 +1,24 @@
1
+ import type { HeadlessPaneContext } from "../../../types/headless";
2
+ import { filingFormMatches } from "../sec/forms";
3
+ import { resolveHeadlessInstrument, resolveHeadlessIssuerListing } from "./headless-market-data";
4
+
5
+ /**
6
+ * Whether the SEC lists a filing of the form for the company, as
7
+ * `filings <ticker> --form <form>` would list it: ASML files 20-F and 6-K, so
8
+ * a route to its 10-Ks or 8-Ks answers "No filings". The service filters by
9
+ * form, so one filing answers; the form is checked again here, as a server
10
+ * without the filter sends the newest filing of any form.
11
+ */
12
+ export async function filesForm(symbol: string, form: string, context: HeadlessPaneContext): Promise<boolean> {
13
+ const [{ symbol: ticker }, listing] = await Promise.all([
14
+ resolveHeadlessInstrument(context, symbol),
15
+ resolveHeadlessIssuerListing(context, symbol),
16
+ ]);
17
+ // A listing abroad whose company is unknown cannot be matched to a registrant, and `filings` says so.
18
+ if (listing.exchange && !listing.name) return false;
19
+ const { filings } = await context.apiClient.getCloudSecFilings(
20
+ { ticker, ...listing, forms: [form], limit: 1 },
21
+ { signal: context.signal },
22
+ );
23
+ return filings.some((filing) => filingFormMatches(filing.form, form));
24
+ }
@@ -11,6 +11,17 @@ export const HISTORY_RANGE_VALUES: HeadlessPaneOptionValue[] = [
11
11
  { value: "ALL", aliases: ["max", "maximum"] },
12
12
  ];
13
13
 
14
+ /**
15
+ * The history windows and YTD, which runs from the prior year's last close
16
+ * (the one year-to-date returns are measured from), for a chart that compares
17
+ * returns.
18
+ */
19
+ export const RETURN_RANGE_VALUES: HeadlessPaneOptionValue[] = [
20
+ ...HISTORY_RANGE_VALUES.slice(0, 5),
21
+ { value: "YTD", aliases: ["year-to-date", "year to date"] },
22
+ ...HISTORY_RANGE_VALUES.slice(5),
23
+ ];
24
+
14
25
  export const FINANCIAL_PERIOD_OPTION: HeadlessPaneOptionDef = {
15
26
  key: "period",
16
27
  description: "Financial reporting period.",
@@ -1,10 +1,90 @@
1
+ import type { HeadlessPaneContext } from "../../../types/headless";
1
2
  import type { PaneLock } from "../../../types/plugin";
3
+ import { settleWithin, signalWithTimeout } from "../../../utils/async-deadline";
4
+ import { logFailure } from "../../../utils/log-failure";
2
5
 
3
6
  /** A lock note on one line, as JSON and CSV say why a locked report is empty and as an error carries it. */
4
7
  export function lockNoteReason(note: string): string {
5
8
  return note.replace(/\s*\n\s*/g, " ");
6
9
  }
7
10
 
11
+ /**
12
+ * A free command a lock note can name in place of the locked function: what
13
+ * it lists, and whether it has data for the symbol the reader asked about. A
14
+ * note never sends a reader to a command that answers "No data".
15
+ */
16
+ export interface FreeRoute {
17
+ /** The command and what it lists, finishing "Free, with no sign-in: ...". Run it signed out before naming it. */
18
+ text(ticker: string, company: string): string;
19
+ /**
20
+ * Whether the route has data for the symbol, read as the route reads it.
21
+ * Leave it out only for a route that answers for every symbol the function
22
+ * takes. A read that fails, or is still out after a few seconds, counts as
23
+ * no data; pass `context.signal` on to reads that take one.
24
+ */
25
+ answers?(symbol: string, context: HeadlessPaneContext): Promise<boolean>;
26
+ }
27
+
28
+ /** A refused read asks for its note twice (the error it throws, then the report), so an answer is kept a while. */
29
+ const ANSWER_TTL_MS = 5 * 60_000;
30
+
31
+ /**
32
+ * How long a note waits on its routes' data checks, all of them together: a
33
+ * check still out then counts as no data, so a stalled service costs the note
34
+ * its route, never the reader's command.
35
+ */
36
+ const ROUTE_CHECK_WAIT_MS = 3_000;
37
+
38
+ interface KeptAnswer { at: number; answer: Promise<boolean> }
39
+ const answers = new WeakMap<FreeRoute, Map<string, KeptAnswer>>();
40
+
41
+ /**
42
+ * Whether the route has data for the symbol, null when its check failed or was
43
+ * still out at the deadline. A check that answered is kept for the TTL; one
44
+ * that failed or ran out of time is dropped, so the next note checks again
45
+ * rather than hiding the route for minutes over one slow read.
46
+ */
47
+ async function routeAnswers(route: FreeRoute, check: NonNullable<FreeRoute["answers"]>, symbol: string, context: HeadlessPaneContext, deadline: number): Promise<boolean | null> {
48
+ const bySymbol = answers.get(route) ?? new Map<string, KeptAnswer>();
49
+ answers.set(route, bySymbol);
50
+ const key = symbol.trim().toUpperCase();
51
+ let kept = bySymbol.get(key);
52
+ if (!kept || Date.now() - kept.at >= ANSWER_TTL_MS) {
53
+ // The check's own reads stop at the deadline where they take a signal.
54
+ const signal = signalWithTimeout(context.signal, Math.max(0, deadline - Date.now()));
55
+ const entry: KeptAnswer = { at: Date.now(), answer: check(symbol, { ...context, signal }) };
56
+ entry.answer.catch(logFailure("plugins.pro-lock-note", "Check a free route's data failed"));
57
+ bySymbol.set(key, entry);
58
+ kept = entry;
59
+ }
60
+ const answer = await settleWithin(kept.answer, Math.max(0, deadline - Date.now()));
61
+ if (answer === null && bySymbol.get(key) === kept) bySymbol.delete(key);
62
+ return answer;
63
+ }
64
+
65
+ /**
66
+ * The first of the routes, best first, that has data for the lock's symbol:
67
+ * GUIDE's guidance ranges where it covers the company, else its 8-Ks. The
68
+ * checks run together under one deadline, so a stalled first route neither
69
+ * holds the note past it nor keeps the next route from being named. Without a
70
+ * symbol the routes read `<ticker>` and the first is named; without a way to
71
+ * read data, only a route that answers for every symbol is.
72
+ */
73
+ async function answeringFreeRoute(lock: PaneLock, routes: readonly FreeRoute[]): Promise<FreeRoute | null> {
74
+ const { symbol, context } = lock;
75
+ if (!symbol) return routes[0] ?? null;
76
+ // Routes past the first that needs no check are never named, so they are not checked.
77
+ const unchecked = routes.findIndex((route) => !route.answers);
78
+ const candidates = unchecked < 0 ? routes : routes.slice(0, unchecked + 1);
79
+ const deadline = Date.now() + ROUTE_CHECK_WAIT_MS;
80
+ const checks = candidates.map((route) => !route.answers ? Promise.resolve(true)
81
+ : context ? routeAnswers(route, route.answers, symbol, context, deadline) : Promise.resolve(false));
82
+ for (const [index, check] of checks.entries()) {
83
+ if (await check) return candidates[index]!;
84
+ }
85
+ return null;
86
+ }
87
+
8
88
  /**
9
89
  * The `reportLockNote` of a pane its wall covers whole (`useProFeatureWall`, a
10
90
  * scanner's denied state): what the function is and that a wall shows none of
@@ -12,8 +92,8 @@ export function lockNoteReason(note: string): string {
12
92
  * to unlock it. One shape for every lock a reader meets in `fn`, so a refused
13
93
  * read never ends in a bare "Sign in required".
14
94
  */
15
- export function proLockNote(
16
- { gate, symbol, token: typed, verifyEmail }: PaneLock,
95
+ export async function proLockNote(
96
+ lock: PaneLock,
17
97
  pane: {
18
98
  /** The function's shortcut, such as `EK`; a pane that serves several names the one run in the lock. */
19
99
  token: string;
@@ -22,10 +102,10 @@ export function proLockNote(
22
102
  /** What the wall hides, as the object of "none of" and "to read": "it" or "them". */
23
103
  shows: "it" | "them";
24
104
  /**
25
- * The free route and what it lists, finishing "Free, with no sign-in: ...".
26
- * Name one only after running it signed out; leave it out when none answers.
105
+ * The free routes, best first: the note names the first with data for the
106
+ * symbol (`answeringFreeRoute`), or none.
27
107
  */
28
- free?(ticker: string, company: string): string;
108
+ free?: readonly FreeRoute[];
29
109
  /** What the wall asks for: Gloom Cloud Pro (default), or only a free Gloom account. */
30
110
  needs?: "pro" | "sign-in";
31
111
  /**
@@ -35,7 +115,8 @@ export function proLockNote(
35
115
  */
36
116
  argument?: "ticker" | "optional" | "none";
37
117
  },
38
- ): string {
118
+ ): Promise<string> {
119
+ const { gate, symbol, token: typed, verifyEmail } = lock;
39
120
  const token = typed ?? pane.token;
40
121
  const ticker = symbol ?? "<ticker>";
41
122
  // A plan refused at the server is Pro whatever the function usually asks for.
@@ -50,9 +131,10 @@ export function proLockNote(
50
131
  : `To read ${pane.shows} in ${token}, open the app (gloomberb), ${account}${pro ? ", then upgrade (Ctrl+P, UPGRADE)" : ""}`;
51
132
  const bare = pane.argument === "none" || (pane.argument === "optional" && !symbol);
52
133
  const command = bare ? `gloomberb fn ${token}` : `gloomberb fn ${token} ${ticker}`;
134
+ const free = pane.free ? await answeringFreeRoute(lock, pane.free) : null;
53
135
  return [
54
136
  `${token} ${what}: ${pane.does}. ${without} it shows none of ${pane.shows}.`,
55
- ...pane.free ? [`Free, with no sign-in: ${pane.free(ticker, symbol ?? "the company")}`] : [],
137
+ ...free ? [`Free, with no sign-in: ${free.text(ticker, symbol ?? "the company")}`] : [],
56
138
  `${unlock}; ${command} then reads with that session.`,
57
139
  ].join("\n");
58
140
  }
@@ -0,0 +1,62 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { usePaneMenuItems } from "../../../components";
3
+ import type { CompositeChartLevel } from "../../../components/chart/composite/levels";
4
+ import { useShortcut } from "../../../react/input";
5
+ import { useDialogState } from "../../../ui/dialog";
6
+ import type { InstrumentRef } from "../../../market-data/request-types";
7
+ import type { BrokerContractRef } from "../../../types/instrument";
8
+ import { canonicalExchange, parsePublicTickerKey } from "../../../utils/exchanges";
9
+ import { scopedBrokerContractIdentityKey } from "../../../utils/instrument-identity";
10
+
11
+ export interface ResearchTradeProjection {
12
+ levels: readonly CompositeChartLevel[];
13
+ contract?: BrokerContractRef;
14
+ mode?: "simulation" | "live" | "unknown";
15
+ busy?: boolean;
16
+ }
17
+ export interface ResearchTrading extends ResearchTradeProjection {
18
+ symbol: string;
19
+ /** A trading-capable broker profile is enabled, so t leads somewhere. */
20
+ available?: boolean;
21
+ docked: boolean;
22
+ visible: boolean;
23
+ /** The docked ticket holds focus; the chart beside it only sees keys the ticket leaves. */
24
+ ticketFocused?: boolean;
25
+ /** Opens the ticket; without a side it keeps its draft, and Buy or Sell is chosen in it. */
26
+ open(side?: "BUY" | "SELL"): void;
27
+ close(): void;
28
+ /** This ticker's working orders and latest fills, for Overview; absent without a trading profile. */
29
+ orders?(width: number): ReactNode;
30
+ publish(projection: ResearchTradeProjection): void;
31
+ }
32
+ const Context = createContext<ResearchTrading | null>(null);
33
+ export const ResearchTradingProvider = Context.Provider;
34
+ export const useResearchTrading = () => useContext(Context);
35
+
36
+ /** Never put an option premium or another venue's price on the underlying chart. */
37
+ export function researchTradeMatches(trade: ResearchTradeProjection | null, listing: InstrumentRef | null): boolean {
38
+ const contract = trade?.contract;
39
+ if (!contract || !listing) return false;
40
+ if (listing.instrument) return scopedBrokerContractIdentityKey(contract) === scopedBrokerContractIdentityKey(listing.instrument);
41
+ if (contract.secType && !["STK", "ETF"].includes(contract.secType)) return false;
42
+ if (contract.symbol.toUpperCase() !== parsePublicTickerKey(listing.symbol).symbol.toUpperCase()) return false;
43
+ const exchange = canonicalExchange(listing.exchange ?? parsePublicTickerKey(listing.symbol).exchange);
44
+ // SMART is a route, not a listing: a share a broker routes is on any of its venues.
45
+ const venue = [contract.exchange, contract.primaryExchange].map(canonicalExchange).find((name) => name && name !== "SMART");
46
+ return !venue || !exchange || venue === exchange;
47
+ }
48
+
49
+ /** Trade stays in the pane menu and keyboard, not the range toolbar: one key, and the ticket picks the side. */
50
+ export function ResearchTradeActions({ focused }: { focused: boolean }) {
51
+ const trade = useResearchTrading();
52
+ const dialog = useDialogState((state) => state.isOpen);
53
+ useShortcut((event) => {
54
+ if (!trade || trade.ticketFocused || event.defaultPrevented || event.propagationStopped || event.targetEditable || event.ctrl || event.alt || event.meta || event.shift || event.super || event.repeat) return;
55
+ if (event.name !== "t") return;
56
+ event.preventDefault(); event.stopPropagation(); trade.open();
57
+ }, { enabled: focused && !!trade && !dialog, phase: "before", scope: "research:trade-open" });
58
+ usePaneMenuItems("research:trade-open", () => trade ? [
59
+ { id: "trade-open", label: "Trade", accelerator: "t", enabled: !trade.busy, onSelect: () => trade.open() },
60
+ ] : null, [trade]);
61
+ return null;
62
+ }
@@ -18,7 +18,7 @@ export const socialMentionsHeadless: HeadlessPaneDefinition<"bundle"> = {
18
18
  const range = (["1y", "5y", "max"].includes(String(args.options.range)) ? args.options.range : "1y") as SocialMentionsRange;
19
19
  const symbol = String(args.symbols[0]).toUpperCase();
20
20
  const data = await fetchSocialMentions(symbol, range, ctx.apiClient)
21
- .catch((error: unknown) => { throw explainRefusal(error, socialMentionsLockNote, { symbol }); });
21
+ .catch(async (error: unknown) => { throw await explainRefusal(error, socialMentionsLockNote, { symbol, context: ctx }); });
22
22
  return { complete: !data.pending.length, errors: data.warnings,
23
23
  sections: [{ title: "Daily posts on X", columns: [
24
24
  { key: "day", header: "Date" },
@@ -5,7 +5,7 @@ import { proLockNote } from "../shared/pro-lock-note";
5
5
  * BUZZ needs a signed-in account, not a plan. No command reads the posts it
6
6
  * counts without one, so the note names no free route.
7
7
  */
8
- export function socialMentionsLockNote(lock: PaneLock): string {
8
+ export function socialMentionsLockNote(lock: PaneLock): Promise<string> {
9
9
  return proLockNote(lock, {
10
10
  token: "BUZZ",
11
11
  needs: "sign-in",
@@ -4,11 +4,15 @@ import type { HeadlessPaneColumn, HeadlessPaneDefinition, HeadlessPaneFreshness
4
4
  import { formatRelativeAge } from "../../../utils/datetime-format";
5
5
  import { fetchThemeMembers, fetchThemes } from "./client";
6
6
  import {
7
- aggregateText, boardClose, DEFAULT_SORT, matchTheme, memberExtended, memberExtendedSessions, memberPrice, percent, PERIOD_LABELS,
7
+ aggregateText, boardClose, boardYtdBase, DEFAULT_SORT, matchTheme, memberExtended, memberExtendedSessions, memberPrice, percent, periodLabel,
8
8
  SNAPSHOT_STALE_MS, sortMembers, sortThemes,
9
9
  } from "./model";
10
10
 
11
- const periods: HeadlessPaneColumn[] = THEME_PERIODS.map((key) => ({ key, header: PERIOD_LABELS[key], align: "right", format: (value) => percent(value as number | null) }));
11
+ /** The period columns; YTD names the close it is measured from, `YTD vs 31 Dec close`. */
12
+ const periodColumns = (payload: ThemesPayload | ThemeMembersPayload): HeadlessPaneColumn[] => {
13
+ const base = boardYtdBase(payload);
14
+ return THEME_PERIODS.map((key) => ({ key, header: periodLabel(key, base), align: "right", format: (value) => percent(value as number | null) }));
15
+ };
12
16
 
13
17
  /** The pre-market or after-hours print and its move from the regular close in Price, for the members that have one. */
14
18
  function extendedColumn(session: ExtendedSession): HeadlessPaneColumn {
@@ -90,6 +94,7 @@ export const themesHeadless: HeadlessPaneDefinition<"rows"> = {
90
94
  const observation = boardObservation(data);
91
95
  // Price and 1D are the regular session's; an extended print gets a column only when a member has one.
92
96
  const extendedSessions = memberExtendedSessions(data.members);
97
+ const periods = periodColumns(data);
93
98
  return { rows: sortMembers(data.members, DEFAULT_SORT).map((row) => {
94
99
  const extended = memberExtended(row);
95
100
  return { ...row, extendedSession: extended?.session ?? null, extendedPrice: extended?.price ?? null,
@@ -104,6 +109,7 @@ export const themesHeadless: HeadlessPaneDefinition<"rows"> = {
104
109
  metadata: { ...observation.metadata, theme: data.theme } };
105
110
  }
106
111
  const observation = boardObservation(board);
112
+ const periods = periodColumns(board);
107
113
  return { rows: sortThemes(board.themes, DEFAULT_SORT).map((row) => ({ name: row.name, memberCount: row.memberCount,
108
114
  ...Object.fromEntries(THEME_PERIODS.map((period) => [period, row.returns[period].value])), breadth: row.breadth.value, coverage: row.returns,
109
115
  breadthCoverage: row.breadth, best: row.best?.symbol ?? null, worst: row.worst?.symbol ?? null })),
@@ -2,9 +2,29 @@ import { THEME_PERIODS, type ThemeAggregate, type ThemeMember, type ThemePeriod,
2
2
  import type { DataTableColumn } from "../../../components";
3
3
  import { dropColumnsToFit, getTableWidth } from "../../../components/ui/table-layout";
4
4
  import { extendedSessionStateLabel, type ExtendedSession } from "../../../market-data/market/status";
5
+ import { yearToDateBaseLabel } from "../../../market-data/market/year-to-date";
5
6
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
6
7
 
7
- export const PERIOD_LABELS: Record<ThemePeriod, string> = { changePercent: "1D", return1WPercent: "1W", return1MPercent: "1M", return3MPercent: "3M", returnYtdPercent: "YTD" };
8
+ const PERIOD_LABELS: Record<ThemePeriod, string> = { changePercent: "1D", return1WPercent: "1W", return1MPercent: "1M", return3MPercent: "3M", returnYtdPercent: "YTD" };
9
+
10
+ /**
11
+ * The close a board's YTD is measured from, `31 Dec`: NYSE's last session of
12
+ * the year before the session the board describes, as the server measures it.
13
+ */
14
+ export function boardYtdBase(payload: { asOf?: unknown; regularSessionDate?: unknown; regularSessionClosedAt?: unknown }, now = Date.now()): string {
15
+ const day = boardClose(payload)?.date ?? (typeof payload.asOf === "string" && /^\d{4}-\d{2}-\d{2}/.test(payload.asOf) ? payload.asOf.slice(0, 10) : new Date(now).toISOString().slice(0, 10));
16
+ return yearToDateBaseLabel(day, "NYSE", now);
17
+ }
18
+
19
+ /**
20
+ * A period's header. YTD names its base close, as long as `room` allows:
21
+ * `YTD vs 31 Dec close`, `YTD vs 31 Dec`, then `YTD 31 Dec`.
22
+ */
23
+ export function periodLabel(period: ThemePeriod, ytdBase: string | null, room = Infinity): string {
24
+ if (period !== "returnYtdPercent" || !ytdBase) return PERIOD_LABELS[period];
25
+ const labels = [`YTD vs ${ytdBase} close`, `YTD vs ${ytdBase}`, `YTD ${ytdBase}`];
26
+ return labels.find((label) => label.length <= room) ?? labels.at(-1)!;
27
+ }
8
28
  export interface ThemeSort { columnId: string; direction: SortDirection }
9
29
  export const DEFAULT_SORT: ThemeSort = { columnId: "changePercent", direction: "desc" };
10
30
  export function percent(value: number | null): string {
@@ -110,17 +130,27 @@ export function matchTheme(rows: readonly ThemeSummary[], input: string): ThemeS
110
130
  }
111
131
 
112
132
  const column = (id: string, label: string, width: number, align: "left" | "right" = "right"): DataTableColumn => ({ id, label, width, align });
113
- /** Drop leaders/laggards first, then longer windows; keep the day's move and breadth. */
114
- export function themeColumns(width: number, rows: readonly ThemeSummary[]): DataTableColumn[] {
133
+ /** Room a header has in its column: the sort indicator takes two cells. */
134
+ const HEADER_ROOM = 2;
135
+ /**
136
+ * Drop leaders/laggards first, then longer windows; keep the day's move and
137
+ * breadth. YTD names `ytdBase`, the close it is measured from, as fully as
138
+ * its column allows.
139
+ */
140
+ export function themeColumns(width: number, rows: readonly ThemeSummary[], ytdBase: string | null = null): DataTableColumn[] {
115
141
  const suffixWidth = (period: ThemePeriod) => coverageWidth(rows.map((row) => row.returns[period]));
116
142
  const metricWidth = (period: ThemePeriod) => Math.max(8, ...rows.map((row) => percent(row.returns[period].value).length)) + suffixWidth(period);
143
+ // The same trailing space is reserved in every cell and its header. A note
144
+ // occupies that space without moving the number away from its label.
145
+ const label = (period: ThemePeriod, room?: number) => periodLabel(period, ytdBase, room) + " ".repeat(suffixWidth(period));
117
146
  const columns = [column("name", "Theme", 26, "left"), column("memberCount", "N", 3),
118
- // The same trailing space is reserved in every cell and its header. A note
119
- // occupies that space without moving the number away from its label.
120
- ...THEME_PERIODS.map((period) => column(period, PERIOD_LABELS[period] + " ".repeat(suffixWidth(period)), metricWidth(period))),
147
+ ...THEME_PERIODS.map((period) => column(period, label(period, 0), Math.max(metricWidth(period), label(period, 0).length + HEADER_ROOM))),
121
148
  column("breadth", "Breadth" + " ".repeat(coverageWidth(rows.map((row) => row.breadth))), 7 + coverageWidth(rows.map((row) => row.breadth))),
122
149
  column("best", "Best 1D", 15), column("worst", "Worst 1D", 15)];
123
- return fitColumns(columns, width, ["worst", "best", "return3MPercent", "returnYtdPercent", "return1MPercent", "return1WPercent", "memberCount"]);
150
+ const fitted = fitColumns(columns, width, ["worst", "best", "return3MPercent", "returnYtdPercent", "return1MPercent", "return1WPercent", "memberCount"]);
151
+ const ytd = fitted.find((col) => col.id === "returnYtdPercent");
152
+ if (ytd) ytd.label = label("returnYtdPercent", ytd.width - HEADER_ROOM - suffixWidth("returnYtdPercent"));
153
+ return fitted;
124
154
  }
125
155
  /**
126
156
  * The member table at `width`. Price and 1D are the regular session's; a
@@ -128,12 +158,15 @@ export function themeColumns(width: number, rows: readonly ThemeSummary[]): Data
128
158
  * each session in `extendedSessions`. A narrow pane gives up the longer
129
159
  * windows first, then that column, then the name.
130
160
  */
131
- export function memberColumns(width: number, extendedSessions: readonly ExtendedSession[] = []): DataTableColumn[] {
161
+ export function memberColumns(width: number, extendedSessions: readonly ExtendedSession[] = [], ytdBase: string | null = null): DataTableColumn[] {
132
162
  const extended = extendedSessions.map((session) => column(session === "PRE" ? "preMarket" : "afterHours", extendedSessionStateLabel(session), 9));
133
- const [day, ...windows] = THEME_PERIODS.map((period) => column(period, PERIOD_LABELS[period], 9));
134
- return fitColumns([column("symbol", "Ticker", 7, "left"), column("name", "Name", 24, "left"), column("price", "Price $", 10),
163
+ const [day, ...windows] = THEME_PERIODS.map((period) => column(period, periodLabel(period, ytdBase, 0), Math.max(9, periodLabel(period, ytdBase, 0).length + HEADER_ROOM)));
164
+ const fitted = fitColumns([column("symbol", "Ticker", 7, "left"), column("name", "Name", 24, "left"), column("price", "Price $", 10),
135
165
  day!, ...extended, ...windows], width,
136
166
  ["return3MPercent", "returnYtdPercent", "return1MPercent", "return1WPercent", "preMarket", "afterHours", "name", "price"]);
167
+ const ytd = fitted.find((col) => col.id === "returnYtdPercent");
168
+ if (ytd) ytd.label = periodLabel("returnYtdPercent", ytdBase, ytd.width - HEADER_ROOM);
169
+ return fitted;
137
170
  }
138
171
  function fitColumns(columns: DataTableColumn[], width: number, drop: string[]): DataTableColumn[] {
139
172
  columns = dropColumnsToFit(columns, width, drop);
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, type ReactNode } from "react";
2
2
  import type { ThemeMember, ThemePeriod, ThemeSummary } from "../../../api-client/themes";
3
- import { DataTableStackView, DataTableView, PaneStatusBody, usePaneNoticeFooter, usePaneStatusFooter, type DataTableCell, type DataTableColumn } from "../../../components";
3
+ import { splitDelayedStatus, DataTableStackView, DataTableView, PaneStatusBody, usePaneNoticeFooter, usePaneStatusFooter, type DataTableCell, type DataTableColumn } from "../../../components";
4
4
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
5
5
  import type { ExtendedSession } from "../../../market-data/market/status";
6
6
  import { useAsyncResource, useAutoRefresh, usePluginPaneState, usePluginTickerActions, useUpdatedAgo } from "../../../public/react";
@@ -12,7 +12,7 @@ import { nextHeaderSort } from "../../../utils/sort-values";
12
12
  import { useMembersMenu } from "../shared/members-menu";
13
13
  import { cachedMembers, cachedThemes, loadMembers, loadThemes } from "./client";
14
14
  import {
15
- aggregateText, coverageWidth, DEFAULT_SORT, extendedMove, matchTheme, memberColumns, memberExtendedSessions, memberPrice, percent,
15
+ aggregateText, boardYtdBase, coverageWidth, DEFAULT_SORT, extendedMove, matchTheme, memberColumns, memberExtendedSessions, memberPrice, percent,
16
16
  SNAPSHOT_STALE_MS, snapshotFooterText, sortMembers, sortThemes, themeColumns, type ThemeSort,
17
17
  } from "./model";
18
18
 
@@ -24,7 +24,7 @@ function useSnapshotFooter(id: string, resource: { loading: boolean; error: stri
24
24
  const payload = data?.payload;
25
25
  // Outside the regular session the figures are a close, so the footer names it, not the snapshot's time.
26
26
  const text = age && payload ? snapshotFooterText(payload, age) : null;
27
- const info = useMemo(() => text ? [{ id: "snapshot", parts: [{ text, tone: "muted" as const }] }] : [], [text]);
27
+ const info = useMemo(() => text ? splitDelayedStatus({ id: "snapshot", parts: [{ text, tone: "muted" }] }) : [], [text]);
28
28
  // The server flag includes retained historical returns; it does not mean the daily board is old.
29
29
  const snapshotStale = !!data && Date.now() - Date.parse(data.payload.asOf) > SNAPSHOT_STALE_MS;
30
30
  usePaneStatusFooter({ registrationId: id, enabled, loading: resource.loading && !!data,
@@ -45,8 +45,10 @@ function Members({ id, width, height, focused }: { id: string; width: number; he
45
45
  const rows = useMemo(() => sortMembers(data?.members ?? [], sort), [data, sort]);
46
46
  // Price and 1D are the regular session's; a pre-market or after-hours print gets its own column when a member has one.
47
47
  const extendedSessionsKey = useMemo(() => memberExtendedSessions(data?.members ?? []).join(","), [data]);
48
- const columns = useMemo(() => memberColumns(width, extendedSessionsKey ? extendedSessionsKey.split(",") as ExtendedSession[] : []),
49
- [extendedSessionsKey, width]);
48
+ // YTD names the close it is measured from, `31 Dec`.
49
+ const ytdBase = data ? boardYtdBase(data) : null;
50
+ const columns = useMemo(() => memberColumns(width, extendedSessionsKey ? extendedSessionsKey.split(",") as ExtendedSession[] : [], ytdBase),
51
+ [extendedSessionsKey, width, ytdBase]);
50
52
  useAutoRefresh(resource.updatedAt, resource.load);
51
53
  usePaneRefreshKey(() => void resource.reload(), { focused });
52
54
  useSnapshotFooter("themes:members", resource, focused);
@@ -103,7 +105,7 @@ export function ThemesBoard({ width, height, focused, tabStrip, tabRows = 0 }: T
103
105
  const resource = useAsyncResource(loadThemes, { initialData: cachedThemes });
104
106
  const data = resource.data?.payload;
105
107
  const rows = useMemo(() => sortThemes(data?.themes ?? [], sort), [data, sort]);
106
- const columns = useMemo(() => themeColumns(width, data?.themes ?? []), [width, data]);
108
+ const columns = useMemo(() => themeColumns(width, data?.themes ?? [], data ? boardYtdBase(data) : null), [width, data]);
107
109
  const theme = opened && data ? matchTheme(data.themes, opened) : null;
108
110
  useAutoRefresh(resource.updatedAt, resource.load);
109
111
  usePaneRefreshKey(() => void resource.reload(), { focused, enabled: !opened });