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,5 +1,6 @@
1
1
  import { apiClient } from "../../../../api-client";
2
2
  import { httpFetch } from "../../../../utils/http-transport";
3
+ import { logFailure } from "../../../../utils/log-failure";
3
4
  import type {
4
5
  BuildoutAccess,
5
6
  BuildoutCompaniesPayload,
@@ -55,7 +56,10 @@ export async function buildoutApi<T>(path: string, token: string | null, init: R
55
56
  if (token) headers.set("Authorization", `Bearer ${token}`);
56
57
  const response = await httpFetch(`${BUILDOUT_API_URL}${path}`, { ...init, headers });
57
58
  if (!response.ok) {
58
- const body = await response.text().catch(() => "");
59
+ const body = await response.text().catch(() => {
60
+ // Intentional ignore: the failed request still throws its status when the error body is unreadable.
61
+ return "";
62
+ });
59
63
  throw new Error(body || `${BUILDOUT_NAME} request failed (${response.status})`);
60
64
  }
61
65
  return response.json() as Promise<T>;
@@ -124,12 +128,17 @@ export async function loadBuildoutData(token: string | null) {
124
128
  export async function getBuildoutProToken() {
125
129
  if (!apiClient.isSignedIn()) return null;
126
130
 
127
- const session = await apiClient.getSession().catch(() => null);
128
- if (!session) return null;
131
+ try {
132
+ const session = await apiClient.getSession();
133
+ if (!session) return null;
129
134
 
130
- const account = await apiClient.getBuildoutAccount().catch(() => null);
131
- if (!account?.subscription.active) return null;
135
+ const account = await apiClient.getBuildoutAccount();
136
+ if (!account?.subscription.active) return null;
132
137
 
133
- const token = await apiClient.getBuildoutToken().catch(() => null);
134
- return token?.token ?? null;
138
+ const token = await apiClient.getBuildoutToken();
139
+ return token?.token ?? null;
140
+ } catch (error) {
141
+ logFailure("buildout.model", "Check access failed")(error);
142
+ throw new Error("Access check failed. Access is unknown.");
143
+ }
135
144
  }
@@ -16,6 +16,7 @@ import { handleRefreshKey } from "../../../../components/data-table/table-pane";
16
16
  import type { PaneProps } from "../../../../types/plugin";
17
17
  import { useOptionalDialog, type PromptContext } from "../../../../ui/dialog";
18
18
  import { isPlainKey } from "../../../../utils/keyboard";
19
+ import { logFailure } from "../../../../utils/log-failure";
19
20
  import { nextHeaderSort, type SortPreference } from "../../../../utils/sort-values";
20
21
  import { usePaneStateValue } from "../../../../state/app/context";
21
22
  import { useInlineTickerQuoteFact, useInlineTickers } from "../../../../state/hooks/inline-tickers";
@@ -201,7 +202,7 @@ export function BuildoutPane({ focused, width, height }: PaneProps) {
201
202
  choices={openTargets.map((target) => ({ id: target.id, label: target.label, detail: target.detail }))}
202
203
  />
203
204
  ),
204
- }).catch(() => undefined);
205
+ }).catch(logFailure("buildout.pane", "Choose open target failed"));
205
206
  const target = openTargets.find((entry) => entry.id === chosen);
206
207
  if (target) openTarget(target);
207
208
  }, [dialog, openTarget, openTargets]);
@@ -383,7 +384,7 @@ export function BuildoutPane({ focused, width, height }: PaneProps) {
383
384
  }
384
385
 
385
386
  if (state.status === "error") {
386
- return <PaneStatusBody error={state.message} subject={BUILDOUT_NAME} width={width} height={height} />;
387
+ return <PaneStatusBody error={state.message} subject={BUILDOUT_NAME} width={width} height={height} retry={{ onRetry: refresh }} />;
387
388
  }
388
389
 
389
390
  const listOpen = activeTab === "companies" && !!selectedList;
@@ -1,4 +1,4 @@
1
- import { PaneStatusBody, type PaneFooterSegment } from "../../../../components";
1
+ import { delayedFooterSegment, splitDelayedStatus, PaneStatusBody, type PaneFooterSegment } from "../../../../components";
2
2
  import type {
3
3
  BuildoutList,
4
4
  BuildoutLoadState,
@@ -44,10 +44,10 @@ export function updateBuildoutFooterInfo(
44
44
 
45
45
  const info: PaneFooterSegment[] = [];
46
46
  if (messages.quoteFreshness) {
47
- info.push({ id: "quote", parts: [{ text: messages.quoteFreshness, tone: "muted" }] });
47
+ info.push(...splitDelayedStatus({ id: "quote", parts: [{ text: messages.quoteFreshness, tone: "muted" }] }));
48
48
  }
49
49
  if (state.access !== "pro" && activeTab === "intel") {
50
- info.push({ id: "access", onPress: messages.onUpgrade, parts: [{ text: "72h delayed", tone: "warning" }] });
50
+ info.push(delayedFooterSegment({ id: "access", detail: "72h delayed", delay: "72h", onPress: messages.onUpgrade }));
51
51
  }
52
52
  if (messages.partialList) {
53
53
  info.push({ id: "partial", onPress: messages.onUpgrade, parts: [{ text: "partial list", tone: "warning" }] });
@@ -10,6 +10,7 @@ import {
10
10
  normalizeChartSpec,
11
11
  } from "../../../time-series/spec";
12
12
  import type { ChartSeriesSpec, ChartSpec, SeriesPeriod } from "../../../time-series/types";
13
+ import { yearToDateBaseDay } from "../../../market-data/market/year-to-date";
13
14
 
14
15
  const PRICE_CAPABILITIES = new Set(["price-chart", "intraday-price-chart", "price-comparison"]);
15
16
 
@@ -20,6 +21,20 @@ function chartRange(options: HeadlessPaneOptionValues): TimeRange | null {
20
21
  : null;
21
22
  }
22
23
 
24
+ /**
25
+ * YTD as a window: from the prior year's last session to today, so a return
26
+ * comparison starts at the close year-to-date returns are measured from (31
27
+ * Dec 2025 for 2026), never at the year's first session. The earliest of the
28
+ * charted listings' last December sessions, a venue's own calendar where it
29
+ * is known.
30
+ */
31
+ export function yearToDateWindow(spec: ChartSpec, now = Date.now()): { start: string; end: string } {
32
+ const today = new Date(now).toISOString().slice(0, 10);
33
+ const venues = [...new Set(spec.series.flatMap((entry) => entry.source.kind === "security" ? [entry.source.instrument.exchange || undefined] : []))];
34
+ const starts = (venues.length ? venues : [undefined]).flatMap((venue) => yearToDateBaseDay(today, venue, now) ?? []);
35
+ return { start: starts.sort()[0] ?? `${Number(today.slice(0, 4)) - 1}-12-31`, end: today };
36
+ }
37
+
23
38
  function chartResolution(options: HeadlessPaneOptionValues): ChartResolution | null {
24
39
  const value = options.chartResolution ?? options.resolution;
25
40
  return typeof value === "string" && (CHART_RESOLUTIONS as readonly string[]).includes(value)
@@ -45,6 +60,10 @@ export function applyChartComposerCapabilityOptions(
45
60
  options: HeadlessPaneOptionValues,
46
61
  ): ChartSpec {
47
62
  let next = spec;
63
+ if ((options.rangePreset ?? options.range) === "YTD") {
64
+ // A year of daily bars, shown from the prior year's last close.
65
+ next = { ...next, viewport: { ...next.viewport, range: "1Y", dateWindow: yearToDateWindow(next) } };
66
+ }
48
67
  const selectedRange = chartRange(options);
49
68
  const range = capabilityId === "intraday-price-chart" && typeof options.session === "string"
50
69
  ? "1D"
@@ -42,6 +42,7 @@ import {
42
42
  } from "./catalog-inventory";
43
43
  import { useCatalogUniverse, useRegisteredSeries } from "./use-series-catalog";
44
44
  import { buildRegisteredSeriesChart } from "./presets";
45
+ import { currencyCodeLabel } from "../../../utils/currency-units";
45
46
 
46
47
  type CatalogColumnId = "series" | "group" | "unit" | "frequency" | "publisher" | "expression";
47
48
  type CatalogColumn = DataTableColumn & { id: CatalogColumnId };
@@ -228,7 +229,7 @@ export function DataCatalogPane({ focused, width, height }: PaneProps) {
228
229
  case "group":
229
230
  return { text: row.group, color: colors.textDim };
230
231
  case "unit":
231
- return { text: row.unit, color: colors.textDim };
232
+ return { text: currencyCodeLabel(row.unit), color: colors.textDim };
232
233
  case "frequency":
233
234
  return { text: FREQUENCY_LABELS[row.frequency], color: colors.textDim };
234
235
  case "publisher":
@@ -1,6 +1,6 @@
1
1
  import type { HeadlessPaneDefinition, HeadlessPaneOptionDef } from "../../../types/headless";
2
2
  import { isSubMinuteInterval, subMinuteDataMessage } from "../../../cli/known-gaps";
3
- import { FINANCIAL_PERIOD_OPTION, FINANCIAL_PERIOD_COUNT_OPTION, HISTORY_RANGE_VALUES } from "../shared/headless-options";
3
+ import { FINANCIAL_PERIOD_OPTION, FINANCIAL_PERIOD_COUNT_OPTION, HISTORY_RANGE_VALUES, RETURN_RANGE_VALUES } from "../shared/headless-options";
4
4
 
5
5
  type PaneSchema = Pick<HeadlessPaneDefinition, "argument" | "options" | "discovery">;
6
6
 
@@ -111,10 +111,10 @@ export const paneSchemas = {
111
111
  options: [
112
112
  {
113
113
  key: "rangePreset",
114
- description: "Price-history window.",
114
+ description: "Price-history window. YTD runs from the prior year's last close.",
115
115
  type: "enum",
116
116
  aliases: ["range"],
117
- values: HISTORY_RANGE_VALUES,
117
+ values: RETURN_RANGE_VALUES,
118
118
  defaultValue: "1Y",
119
119
  },
120
120
  {
@@ -232,7 +232,8 @@ export async function loadChartPaneModel(
232
232
  ...(chart.priceHistoryIntegrity?.length ? { priceHistoryIntegrity: chart.priceHistoryIntegrity } : {}),
233
233
  ...(valuationPriceIssues.length ? { valuationPriceIssues } : {}),
234
234
  ...(periodCoverage.length ? { periodCoverage } : {}),
235
- notices: [...chart.warnings.filter((warning) => warning === FINANCIAL_VINTAGE_NOTICE || warning === SEC_EPS_BASIS_NOTICE || warning === chart.priceComparison?.notice), ...integrityNotices],
235
+ notices: [...chart.warnings.filter((warning) => warning === FINANCIAL_VINTAGE_NOTICE || warning === SEC_EPS_BASIS_NOTICE || warning === chart.priceComparison?.notice
236
+ || chart.delistings?.some((delisting) => delisting.notice === warning)), ...integrityNotices],
236
237
  priceComparison: chart.priceComparison ?? null,
237
238
  summaries: chart.series.filter((series) => !series.profile).map((series) => ({ id: series.id, ...summarizeResolvedSeries(series) })),
238
239
  },
@@ -0,0 +1,39 @@
1
+ import { useMemo } from "react";
2
+ import { useChartQueries, useResolvedEntryValue } from "../../../market-data/hooks";
3
+ import { instrumentFromTicker, type ChartRequest } from "../../../market-data/request-types";
4
+ import { buildChartKey } from "../../../market-data/selectors";
5
+ import { getTimeRangeForDateWindow } from "../../../time-series/date-window";
6
+ import type { TimeRange } from "../../../time-series/range";
7
+ import type { PricePoint } from "../../../types/financials";
8
+ import type { TickerRecord } from "../../../types/ticker";
9
+ import { resolveOptionsTarget } from "../../../utils/options";
10
+
11
+ /**
12
+ * The shortest range that reaches back to a contract's first daily bar. An
13
+ * option lists months before it expires and may trade a handful of times, so
14
+ * a stock's five years would hold one bar.
15
+ */
16
+ function optionChartRange(points: readonly Pick<PricePoint, "date">[], now: number): TimeRange | null {
17
+ const first = points.reduce<number | null>((oldest, point) => {
18
+ const time = new Date(point.date).getTime();
19
+ return Number.isFinite(time) && (oldest === null || time < oldest) ? time : oldest;
20
+ }, null);
21
+ if (first === null) return null;
22
+ const range = getTimeRangeForDateWindow({ start: new Date(first), end: new Date(now) });
23
+ // A contract that first traded today still opens on a week: one session of minute bars reads as noise.
24
+ return range === "1D" ? "1W" : range;
25
+ }
26
+
27
+ /**
28
+ * The range an option contract's chart opens on, from its own daily history:
29
+ * a month until that answers, which is most of a weekly's life. Null for
30
+ * anything that is not an option.
31
+ */
32
+ export function useOptionChartRange(ticker: TickerRecord | null | undefined): TimeRange | null {
33
+ const instrument = resolveOptionsTarget(ticker)?.isOptionTicker ? instrumentFromTicker(ticker) : null;
34
+ const request: ChartRequest | null = instrument
35
+ ? { instrument, bufferRange: "ALL", granularity: "resolution", resolution: "1d" } : null;
36
+ const entries = useChartQueries(request ? [request] : []);
37
+ const history = useResolvedEntryValue(request ? entries.get(buildChartKey(request)) ?? null : null);
38
+ return useMemo(() => !request ? null : (history?.length ? optionChartRange(history, Date.now()) : null) ?? "1M", [history, !request]);
39
+ }
@@ -116,6 +116,7 @@ import { resolveChartComposerShortcut } from "./shortcuts";
116
116
  import { describeChartResolution, formatChartDateWindow, formatChartResolution } from "./viewport-labels";
117
117
  import { intradaySessionTimeZone } from "./session-history";
118
118
  import { ChartSeriesQuickAdd } from "./quick-add";
119
+ import { ResearchTradeActions, researchTradeMatches, useResearchTrading } from "../shared/research-trading";
119
120
  import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
120
121
  import { usePluginAppActions, usePluginConfigState } from "../../runtime";
121
122
  import { usePluginRenderContext } from "../../runtime/context";
@@ -123,6 +124,7 @@ import { activePriceAlertsFor, addLevelAlert, PRICE_ALERTS_STORE, type AlertTarg
123
124
  import { formatAlertDescription } from "../alerts/alert-engine";
124
125
  import { alertInstrument, syncAlertQuoteStream } from "../alerts/live";
125
126
  import { getSharedMarketDataCoordinator } from "../../../market-data/coordinator";
127
+ import { useOptionChartRange } from "./option-range";
126
128
  import { getActiveQuoteDisplay } from "../../../market-data/market/status";
127
129
  import { resolveEntryData } from "../../../market-data/selectors";
128
130
  import { editPriceLevels, parsePriceLevels, PRICE_LEVELS_KEY, priceLevelTickerKey } from "./price-levels";
@@ -288,6 +290,7 @@ function ChartComposerSurface({
288
290
  onCapture,
289
291
  nameSeries = true,
290
292
  }: ChartComposerSurfaceProps) {
293
+ const trade = useResearchTrading();
291
294
  const dialog = useDialog();
292
295
  const dispatch = useAppDispatch();
293
296
  const { cellWidthPx = 8 } = useUiCapabilities();
@@ -514,6 +517,7 @@ function ChartComposerSurface({
514
517
  return {
515
518
  seriesId: listing.seriesId,
516
519
  items: [
520
+ ...(trade && researchTradeMatches(trade, { ...listing.instrument, exchange: listing.target.exchange }) ? trade.levels : []),
517
521
  ...stored.map((level) => ({
518
522
  id: level.id,
519
523
  value: level.price,
@@ -532,7 +536,7 @@ function ChartComposerSurface({
532
536
  onEdit: editLevels,
533
537
  ...(alertsOn ? { action: { label: "alert", title: "Alert at Level", run: (level) => { void alertAtLevel(level.value); } } } : {}),
534
538
  };
535
- }, [alertAtLevel, alertsJson, alertsOn, editLevels, levelStore, listing]);
539
+ }, [alertAtLevel, alertsJson, alertsOn, editLevels, levelStore, listing, trade?.levels, trade?.contract]);
536
540
  const [interactionCaptured, setInteractionCapturedState] = useState(false);
537
541
  // Typing in quick-add must not freeze the plot: it only takes the keyboard.
538
542
  const [modalCaptured, setModalCaptured] = useState(false);
@@ -949,8 +953,15 @@ function ChartComposerSurface({
949
953
  ? resolution.legendSeries?.filter((entry) => resolution.priceComparison?.seriesIds.includes(entry.id))
950
954
  .map((entry) => `${entry.label}: no observations in the selected date range.`)
951
955
  : []);
956
+ // A charted listing that no longer trades is said in the footer itself: "Delisted Oct 27, 2022" when it
957
+ // is the chart's one listing, which the title names, else "TWTR: Delisted Oct 27, 2022" for each.
958
+ const delistings = resolution.delistings;
959
+ const chartedListings = new Set(spec.series.flatMap((entry) => entry.source.kind === "security" ? [entry.source.instrument.symbol] : []));
960
+ const delistingKey = delistings?.length === 1 && chartedListings.size === 1 ? delistings[0]!.label
961
+ : delistings?.map((delisting) => delisting.notice).join(" · ") ?? "";
952
962
  const statusWarnings = resolution.warnings.filter((warning) => (
953
963
  warning !== FINANCIAL_VINTAGE_NOTICE && warning !== SEC_EPS_BASIS_NOTICE && warning !== comparisonNotice
964
+ && !delistings?.some((delisting) => delisting.notice === warning)
954
965
  && !FORWARD_VALUATION_BASIS_NOTICES.has(warning)
955
966
  && !duplicateErrorNotices.has(warning) && !comparisonEmptyNotices.has(warning)
956
967
  && !spec.studies.some((study) => (
@@ -1002,21 +1013,26 @@ function ChartComposerSurface({
1002
1013
  return newest > end ? formatCompositeDatasetDate(new Date(newest), end) : null;
1003
1014
  }, [activeRuntimeViewport, plottedSeries, spec, viewport]);
1004
1015
  usePaneFooter(footerId, () => ({
1005
- info: loading
1006
- ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }]
1007
- : pickingAnchor
1008
- ? [{ id: "anchor", parts: [{ text: "pick a bar to anchor VWAP", tone: "muted" as const }] }]
1009
- : newestBeyondView
1010
- ? [{ id: "newest", asOf: true, parts: [{ text: `newest ${newestBeyondView}`, tone: "muted" as const }] }]
1011
- : [],
1016
+ info: [
1017
+ ...(delistingKey ? [{ id: "delisted", parts: [{ text: delistingKey, tone: "warning" as const }] }] : []),
1018
+ ...(loading
1019
+ ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }]
1020
+ : pickingAnchor
1021
+ ? [{ id: "anchor", parts: [{ text: "pick a bar to anchor VWAP", tone: "muted" as const }] }]
1022
+ : newestBeyondView
1023
+ ? [{ id: "newest", asOf: true, parts: [{ text: `newest ${newestBeyondView}`, tone: "muted" as const }] }]
1024
+ : []),
1025
+ ],
1012
1026
  hints: [
1027
+ // One key opens the ticket, which chooses Buy or Sell; [s]eries keeps its key with trading on.
1028
+ ...(trade?.available ? [{ id: "trade", key: "t", label: "rade", title: "Open the trade panel", onPress: () => trade.open(), disabled: trade.busy }] : []),
1013
1029
  { id: "series", key: "s", label: "eries", onPress: footerSeries },
1014
1030
  { id: "indicators", key: "i", label: "ndicators", onPress: openIndicators, disabled: indicatorsDisabled },
1015
1031
  { id: "formulas", key: "f", label: "ormulas", onPress: openFormulas, disabled: formulasDisabled },
1016
- { id: "resolution", key: "t", label: "imeframe", onPress: footerResolution },
1032
+ { id: "resolution", key: "b", label: "ars", title: "Bar Size", onPress: footerResolution },
1017
1033
  ...(anchorable ? [{ id: "vwap-anchor", key: "v", label: "wap anchor", title: "Anchor VWAP", onPress: footerAnchor }] : []),
1018
1034
  ],
1019
- }), [loading, pickingAnchor, newestBeyondView, footerSeries, openIndicators, indicatorsDisabled, openFormulas, formulasDisabled, footerResolution, anchorable, footerAnchor]);
1035
+ }), [delistingKey, loading, pickingAnchor, newestBeyondView, footerSeries, openIndicators, indicatorsDisabled, openFormulas, formulasDisabled, footerResolution, anchorable, footerAnchor, trade]);
1020
1036
 
1021
1037
  // A fixed window (a GIP session) highlights no range, so the bar names it.
1022
1038
  const dateWindowLabel = useMemo(() => {
@@ -1097,6 +1113,8 @@ function ChartComposerSurface({
1097
1113
 
1098
1114
  return (
1099
1115
  <Box flexDirection="column" width={width} height={height}>
1116
+ {/* Renders nothing: b and s open the trade panel. The query bar keeps the full row. */}
1117
+ <ResearchTradeActions focused={shortcutActive} />
1100
1118
  <QueryBar
1101
1119
  width={width}
1102
1120
  filters={[
@@ -1286,7 +1304,9 @@ export function ChartComposerPane({ paneId, focused, width, height }: PaneProps)
1286
1304
  export function ChartComposerResearchTab({ focused, width, height, onCapture }: TickerResearchTabProps) {
1287
1305
  const { symbol: paneSymbol, ticker } = usePaneTicker();
1288
1306
  const symbol = paneSymbol ? publicTickerKey(paneSymbol, ticker?.metadata.exchange) : null;
1289
- const fallback = useMemo(() => symbol ? buildPriceChartPreset(symbol) : buildEmptyChartPreset(), [symbol]);
1307
+ // An option contract opens on the range its own history fills; a chart the researcher set keeps its range.
1308
+ const optionRange = useOptionChartRange(ticker);
1309
+ const fallback = useMemo(() => symbol ? buildPriceChartPreset(symbol, optionRange ?? undefined) : buildEmptyChartPreset(), [optionRange, symbol]);
1290
1310
  const [storedSpec, setStoredSpec] = usePaneSettingValue<unknown>(CHART_SPEC_SETTING_KEY, fallback);
1291
1311
  const spec = useMemo(() => parseChartSpecOr(storedSpec, fallback), [fallback, storedSpec]);
1292
1312
  const previousSymbolRef = useRef(symbol);
@@ -222,11 +222,11 @@ function placedRegisteredSeries(expression: ParsedSeriesExpression) {
222
222
  };
223
223
  }
224
224
 
225
- export function buildPriceChartPreset(symbol: string): ChartSpec {
225
+ export function buildPriceChartPreset(symbol: string, range?: TimeRange): ChartSpec {
226
226
  const normalized = normalizeInstrument(symbol, true);
227
227
  if (!normalized) return buildEmptyChartPreset();
228
228
  return setBuiltinStudies(
229
- chartSpec([buildSeriesSpec({ kind: "security", ...normalized, fieldId: CHART_FIELD_IDS.price }, 0)]),
229
+ chartSpec([buildSeriesSpec({ kind: "security", ...normalized, fieldId: CHART_FIELD_IDS.price }, 0)], { range }),
230
230
  ["volume"],
231
231
  );
232
232
  }
@@ -15,6 +15,7 @@ import {
15
15
  } from "../../../utils/exchanges";
16
16
  import { parseSeriesExpression, type ParsedSeriesExpression } from "./series-expression";
17
17
  import { parseFormulaExpression } from "./formula";
18
+ import { currencyCodeLabel } from "../../../utils/currency-units";
18
19
 
19
20
  export interface SeriesCatalogInstrument {
20
21
  symbol: string;
@@ -351,11 +352,12 @@ function registeredSeriesSuggestions(
351
352
  }
352
353
  hits.sort((left, right) => left.rank - right.rank || left.entry.name.length - right.entry.name.length);
353
354
  return hits.map(({ entry, expression, lines, formula, symbol }): SeriesCatalogSuggestion => {
354
- const description = [entry.group, entry.unit, entry.frequency].filter(Boolean).join(" · ");
355
+ const unit = currencyCodeLabel(entry.unit);
356
+ const description = [entry.group, unit, entry.frequency].filter(Boolean).join(" · ");
355
357
  const detail = entry.group ?? "Series";
356
358
  if (formula !== undefined) {
357
359
  // A formula runs long: its name leads, and the formula is in the description.
358
- const formulaDescription = [formula, entry.unit, entry.frequency].filter(Boolean).join(" · ");
360
+ const formulaDescription = [formula, unit, entry.frequency].filter(Boolean).join(" · ");
359
361
  return { id: `formula:${formula}`, label: entry.name, description: formulaDescription, detail, formula };
360
362
  }
361
363
  const placed = {
@@ -13,11 +13,11 @@ export function resolveChartComposerShortcut(
13
13
  ): ChartComposerShortcut | null {
14
14
  if (event.defaultPrevented || event.propagationStopped || event.targetEditable) return null;
15
15
 
16
- // `r` is the app-wide refresh key, so it reloads here too and the resolution
17
- // picker moved to its own mnemonic, [t]imeframe.
16
+ // `r` is the app-wide refresh key, so it reloads here too, and `t` is Trade
17
+ // wherever a chart can trade: the resolution picker is [b]ars, the bar size.
18
18
  if (isPlainKey(event, "s")) return "series";
19
19
  if (isPlainKey(event, "r")) return "reload";
20
- if (isPlainKey(event, "t")) return "resolution";
20
+ if (isPlainKey(event, "b")) return "resolution";
21
21
  if (!isPlainKey(event, event.name ?? "")) return null;
22
22
 
23
23
  const rangeIndex = Number(event.name) - 1;
@@ -55,7 +55,8 @@ function calendarParts(date: Date, timeZone: string): { key: string; year: numbe
55
55
  /**
56
56
  * A fixed chart window as the bar states it: one exchange session reads as
57
57
  * "Sep 23 session", a longer window as "Sep 17 to Sep 23". Dates are taken in
58
- * the exchange's zone, so a session that crosses UTC midnight stays one day.
58
+ * the exchange's zone, so a session that crosses UTC midnight stays one day;
59
+ * a window of calendar days (YYYY-MM-DD) names those days.
59
60
  */
60
61
  export function formatChartDateWindow(
61
62
  window: { start: string; end: string },
@@ -65,7 +66,9 @@ export function formatChartDateWindow(
65
66
  const start = new Date(window.start);
66
67
  const end = new Date(window.end);
67
68
  if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined;
68
- let zone = timeZone;
69
+ // A window of calendar days (2025-12-31 to 2026-10-20) names those days, not UTC midnight in the exchange's zone.
70
+ const calendarDays = [window.start, window.end].every((value) => /^\d{4}-\d{2}-\d{2}$/.test(value));
71
+ let zone = calendarDays ? "UTC" : timeZone;
69
72
  try {
70
73
  new Intl.DateTimeFormat("en-US", { timeZone: zone });
71
74
  } catch {
@@ -136,7 +136,7 @@ export const chatModule: PluginModule = {
136
136
  id: UNREAD_INBOX_TEMPLATE_ID,
137
137
  paneId: UNREAD_INBOX_PANE_ID,
138
138
  label: "Unread",
139
- description: "Unread chat, then price alerts, catalysts and team notices you missed",
139
+ description: "Unread chat, then price alerts, catalysts, economic releases and team notices you missed",
140
140
  keywords: ["unread", "chat", "inbox", "mentions", "missed", "notifications", "alerts"],
141
141
  createInstance: () => ({
142
142
  placement: "floating",
@@ -49,7 +49,7 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
49
49
  const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
50
50
  const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
51
51
  const [snapshot, setSnapshot] = useState(initialSnapshot);
52
- // What the Unread list shows as unread: chat, then missed price, catalyst and team notices.
52
+ // What the Unread list shows as unread: chat, then missed price, catalyst, economic release and team notices.
53
53
  const notices = useSyncExternalStore(subscribeNotificationLog, getNotificationLog, getNotificationLog);
54
54
  const unreadCount = unreadTotal(snapshot, notices);
55
55
 
@@ -55,6 +55,7 @@ const COLUMNS: DataTableColumn[] = [
55
55
  const NOTICE_LABELS: Record<MissedNoticeKind, string> = {
56
56
  price: "Price alert",
57
57
  catalyst: "Catalyst",
58
+ econ: "Economic release",
58
59
  team: "Team",
59
60
  };
60
61
 
@@ -193,7 +194,7 @@ export function UnreadInboxPane({
193
194
  });
194
195
  }, [createPaneFromTemplate, dispatch, inboxInstanceId, stateRef]);
195
196
 
196
- // A notice opens where its toast did: the price rule on ALRT Prices, the catalyst, the team card's place.
197
+ // A notice opens where its toast did: the price rule on ALRT Prices, the catalyst, ECO, the team card's place.
197
198
  const openNotice = useCallback((notice: NotificationLogEntry) => {
198
199
  const ref = parseNoticeRef(notice.refId);
199
200
  if (!ref) return;
@@ -207,6 +208,10 @@ export function UnreadInboxPane({
207
208
  createPaneFromTemplate("catalysts-pane", { values: { event: ref.id } });
208
209
  return;
209
210
  }
211
+ if (ref.kind === "econ") {
212
+ showPane("econ-calendar");
213
+ return;
214
+ }
210
215
  const card = teamStore.getSnapshot().notifications.find((entry) => entry.id === ref.id) ?? null;
211
216
  const action = card ? teamStore.notificationAction(card) : null;
212
217
  if (action) action.onClick();
@@ -93,7 +93,7 @@ export function listUnreadInboxItems(options: {
93
93
 
94
94
  /**
95
95
  * A row of the Unread list: a channel with unread chat, or a missed price,
96
- * catalyst or team notice from the device's log, read or not.
96
+ * catalyst, economic release or team notice from the device's log, read or not.
97
97
  */
98
98
  export type UnreadListRow =
99
99
  | { kind: "chat"; key: string; item: UnreadInboxItem }
@@ -21,8 +21,10 @@ export interface CommodityBalances {
21
21
  const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
22
22
  const text = (value: unknown): value is string => typeof value === "string";
23
23
 
24
+ const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
24
25
  const validRow = (row: BalanceRow) => [row.id, row.crop, row.country, row.attribute, row.unit, row.year].every(text)
25
- && [row.value, row.cropOrder, row.countryOrder, row.attributeOrder].every(finite);
26
+ && [row.value, row.cropOrder, row.countryOrder, row.attributeOrder].every(finite)
27
+ && (row.month == null || (text(row.month) && MONTH.test(row.month)));
26
28
  const validPrice = (row: PriceForecast) => [row.id, row.crop, row.unit, row.season].every(text)
27
29
  && [row.forecast, row.cropOrder, row.seasonYear].every(finite);
28
30
 
@@ -31,7 +33,7 @@ function readBoard(payload: PsdBoardPayload): CommodityBalances {
31
33
  if (!payload || payload.source !== "USDA" || !Array.isArray(payload.rows) || !Array.isArray(payload.prices)
32
34
  || !Array.isArray(payload.gaps) || !payload.gaps.every(text)
33
35
  || !payload.rows.every(validRow) || !payload.prices.every(validPrice)
34
- || (payload.month != null && !/^\d{4}-(0[1-9]|1[0-2])$/.test(payload.month))
36
+ || (payload.month != null && !MONTH.test(payload.month))
35
37
  || (payload.marketYear != null && !text(payload.marketYear))) {
36
38
  throw new Error("The server returned an invalid crop balance board");
37
39
  }
@@ -47,8 +49,8 @@ function readBoard(payload: PsdBoardPayload): CommodityBalances {
47
49
  }
48
50
 
49
51
  /**
50
- * USDA's world wheat, corn, soybean and cotton balances for the latest market
51
- * year, and the season-average price forecasts. Gloom Cloud reads USDA's
52
+ * USDA's world wheat, corn, soybean, cotton, coffee and sugar balances for the
53
+ * latest market year, and the season-average price forecasts. Gloom Cloud reads USDA's
52
54
  * files; a route the server does not serve yet (404) or cannot read (503) is
53
55
  * the not-available message.
54
56
  */
@@ -1,13 +1,25 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import { fetchCommodityBalances } from "./client";
3
- import { formatBalanceValue, formatForecast } from "./model";
3
+ import { cropEstimateMonth, formatBalanceValue, formatForecast, PSD_CROPS, psdCrop, unknownCropMessage } from "./model";
4
+
5
+ /**
6
+ * USDA sets coffee and sugar twice a year, in its own reports rather than the
7
+ * monthly WASDE, and forecasts no season-average price for either.
8
+ */
9
+ const SEMIANNUAL_CROPS = new Set(["Coffee", "Sugar"]);
4
10
 
5
11
  const amount = (value: unknown) => typeof value === "number" ? formatBalanceValue(value) : "--";
6
12
  const forecast = (value: unknown) => typeof value === "number" ? formatForecast(value) : "--";
7
13
 
8
14
  export const commodityBalancesHeadless: HeadlessPaneDefinition<"bundle"> = {
9
15
  shape: "bundle",
10
- argument: { kind: "none" },
16
+ argument: {
17
+ kind: "free-text",
18
+ optional: true,
19
+ placeholder: "crop",
20
+ description: "Wheat, corn, soybeans, cotton, coffee or sugar; omit for every crop.",
21
+ examples: ["coffee", "sugar"],
22
+ },
11
23
  options: [],
12
24
  discovery: {
13
25
  aliases: ["PSD"],
@@ -15,18 +27,40 @@ export const commodityBalancesHeadless: HeadlessPaneDefinition<"bundle"> = {
15
27
  limitations: [
16
28
  "Latest market year only",
17
29
  "Largest producers, and the world total when the file has one",
18
- "Cotton is in 480 lb bales; grains and soybeans are in thousand metric tons",
30
+ "Cotton is in 480 lb bales and coffee in thousand 60 kg bags; grains, soybeans and sugar are in thousand metric tons",
31
+ "Coffee and sugar are USDA's semiannual estimates; price forecasts cover grains, soybeans and cotton",
19
32
  ],
20
33
  },
21
- describe: "Wheat, corn, soybean and cotton balances",
34
+ describe: "Wheat, corn, soybean, cotton, coffee and sugar balances",
22
35
  freshness: { source: "USDA", status: "not-a-feed", basis: "monthly supply and demand estimates", periodicity: "monthly" },
23
- async load(_args, ctx) {
24
- const data = await fetchCommodityBalances(ctx.signal);
36
+ async load(args, ctx) {
37
+ const typed = typeof args.argument === "string" && args.argument.trim() ? args.argument.trim() : null;
38
+ const crop = typed ? psdCrop(typed) : null;
39
+ if (typed && !crop) throw new Error(unknownCropMessage(typed));
40
+ const board = await fetchCommodityBalances(ctx.signal, ctx.apiClient);
41
+ const rows = crop ? board.rows.filter((row) => row.crop === crop) : board.rows;
42
+ // A crop the server does not serve yet is said, not printed as an empty table.
43
+ if (crop && rows.length === 0) throw new Error(`${crop} balances are not available yet.`);
44
+ const prices = crop ? board.prices.filter((row) => row.crop === crop) : board.prices;
45
+ // One crop is dated by its own estimates: coffee's are not the month's WASDE.
46
+ const month = (crop ? cropEstimateMonth(rows) : null) ?? board.month;
47
+ const marketYear = crop ? [...new Set(rows.map((row) => row.year))].join(" ") : board.marketYear;
48
+ const semiannual = crop != null && SEMIANNUAL_CROPS.has(crop);
49
+ // Another crop's gap ("Soybean balances are unavailable.") is not this one's; one naming no crop stays,
50
+ // but for a crop without price forecasts, not theirs.
51
+ const names = (name: string, gap: string) => new RegExp(`\\b${name.replace(/s$/, "")}`, "i").test(gap);
52
+ const gaps = crop
53
+ ? board.gaps.filter((gap) => names(crop, gap)
54
+ || (!PSD_CROPS.some((name) => names(name, gap)) && !(semiannual && /\bprice forecasts?\b/i.test(gap))))
55
+ : board.gaps;
56
+ const data = { ...board, rows, prices, month, marketYear, gaps };
25
57
  return {
58
+ // Coffee and sugar are not the month's supply and demand estimates: `Jul 2026 (semiannual)`.
59
+ ...(semiannual ? { freshness: { basis: "supply and distribution estimates", periodicity: "semiannual" as const } } : {}),
26
60
  complete: data.rows.length > 0,
27
61
  sections: [
28
62
  {
29
- title: data.marketYear ? `Balances ${data.marketYear}` : "Balances",
63
+ title: `${crop ? `${crop} balances` : "Balances"}${data.marketYear ? ` ${data.marketYear}` : ""}`,
30
64
  columns: [
31
65
  { key: "crop", header: "Crop" },
32
66
  { key: "country", header: "Country" },
@@ -1,5 +1,7 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
+ import { commandRequestParams } from "../shared/command-tab";
2
3
  import { commodityBalancesHeadless } from "./headless";
4
+ import { PSD_CROPS, psdCrop, unknownCropMessage } from "./model";
3
5
  import { CommodityBalancesPane } from "./pane";
4
6
 
5
7
  export const commodityBalancesModule: PluginModule = {
@@ -22,11 +24,24 @@ export const commodityBalancesModule: PluginModule = {
22
24
  id: "commodity-balances-pane",
23
25
  paneId: "commodity-balances",
24
26
  label: "Crop Balances",
25
- description: "World balances for wheat, corn, soybeans and cotton, with season-average price forecasts when a table is published.",
26
- keywords: ["wheat", "corn", "soybeans", "cotton", "crop", "balances", "production", "exports", "stocks", "harvest"],
27
- shortcut: { prefix: "PSD" },
27
+ description: "World balances for wheat, corn, soybeans, cotton, coffee and sugar, with season-average price forecasts when a table is published.",
28
+ keywords: ["wheat", "corn", "soybeans", "cotton", "coffee", "sugar", "crop", "balances", "production", "exports", "stocks", "harvest"],
29
+ shortcut: {
30
+ prefix: "PSD",
31
+ argKind: "text",
32
+ argPlaceholder: "crop",
33
+ argOptional: true,
34
+ openWithoutArg: true,
35
+ argOptions: () => PSD_CROPS.map((crop) => ({ value: crop.toLowerCase(), label: crop })),
36
+ },
28
37
  headless: commodityBalancesHeadless,
29
- createInstance: () => ({ placement: "floating" }),
38
+ // PSD coffee opens the board at coffee's first row.
39
+ createInstance: (_context, options) => {
40
+ const typed = options?.arg?.trim();
41
+ const crop = typed ? psdCrop(typed) : null;
42
+ if (typed && !crop) throw new Error(unknownCropMessage(typed));
43
+ return { placement: "floating", ...(crop ? { params: commandRequestParams({ crop }) } : {}) };
44
+ },
30
45
  },
31
46
  ],
32
47
  };