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
@@ -0,0 +1,279 @@
1
+ import type { GloomPluginContext } from "../../../types/plugin";
2
+ import {
3
+ getCalendarCache,
4
+ landedHighImpactPrints,
5
+ loadCalendar,
6
+ pendingHighImpactPrints,
7
+ PRINT_PENDING_MS,
8
+ subscribeCalendar,
9
+ type EconCalendarCacheEntry,
10
+ } from "./calendar-model";
11
+ import type { EconEvent } from "./types";
12
+
13
+ /** The ECO setting that turns the print toast on. Off until the user turns it on, as breaking news is. */
14
+ export const ECON_PRINT_NOTIFICATIONS_ENABLED_KEY = "econPrintNotificationsEnabled";
15
+
16
+ const TOAST_DURATION_MS = 20_000;
17
+ /**
18
+ * The waits between asks while a print is due: short just after the release
19
+ * time, when most prints land, then longer until the window closes, which
20
+ * keeps a release to about twenty asks per app instead of eighty.
21
+ */
22
+ const PRINT_BACKOFF_MS = [15_000, 30_000, 60_000] as const;
23
+ /** Each wait moves by up to a fifth either way, so apps that started together drift apart. */
24
+ const PRINT_JITTER = 0.2;
25
+ /** How often the watch looks at the calendar it already has while nothing is due. */
26
+ const PRINT_IDLE_MS = 60_000;
27
+ /** While idle, a calendar this old is asked for again, so a release added since is known. */
28
+ const CALENDAR_RECHECK_MS = 6 * 60 * 60 * 1000;
29
+ /** With no calendar at all, how long a failed ask waits before the next. */
30
+ const NO_CALENDAR_RETRY_MS = 5 * 60 * 1000;
31
+
32
+ type CalendarRead = Pick<EconCalendarCacheEntry, "data" | "fetchedAt">;
33
+
34
+ export interface PrintWatchDeps {
35
+ now: () => number;
36
+ /** Asks for the calendar; `force` skips a fresh cache. */
37
+ load: (force: boolean) => Promise<CalendarRead>;
38
+ /** The cached calendar, however old, without a request. */
39
+ read: () => CalendarRead | null;
40
+ /** Called whenever anyone, a pane included, loads the calendar. */
41
+ subscribe: (listener: () => void) => () => void;
42
+ schedule: (run: () => Promise<void> | void, delay: number) => () => void;
43
+ random: () => number;
44
+ }
45
+
46
+ const DEFAULT_DEPS: PrintWatchDeps = {
47
+ now: Date.now,
48
+ load: (force) => loadCalendar(force),
49
+ read: () => getCalendarCache({ allowExpired: true }),
50
+ subscribe: subscribeCalendar,
51
+ schedule: (run, delay) => {
52
+ const timer = setTimeout(run, delay);
53
+ return () => clearTimeout(timer);
54
+ },
55
+ random: Math.random,
56
+ };
57
+
58
+ function printBody(event: EconEvent): string {
59
+ return event.forecast ? `${event.event} ${event.actual} vs ${event.forecast}` : `${event.event} ${event.actual}`;
60
+ }
61
+
62
+ type PrintWatchContext = Pick<GloomPluginContext, "notify" | "showPane">;
63
+
64
+ /**
65
+ * Toasts a high-impact print once, when it lands within
66
+ * {@link PRINT_PENDING_MS} of its release time. While one is due the
67
+ * calendar is asked for on {@link PRINT_BACKOFF_MS}, and every read is
68
+ * compared: the watch's own, its idle look at the cache, and a pane's
69
+ * refresh. The first read only records what has already printed.
70
+ */
71
+ export function startHighImpactPrintWatch(
72
+ ctx: PrintWatchContext,
73
+ overrides: Partial<PrintWatchDeps> = {},
74
+ ): () => void {
75
+ const deps: PrintWatchDeps = { ...DEFAULT_DEPS, ...overrides };
76
+ let stopped = false;
77
+ let running = false;
78
+ let cancel: (() => void) | null = null;
79
+ /** The newest calendar the watch has seen. */
80
+ let latest: CalendarRead | null = null;
81
+ let lastAskAt = Number.NEGATIVE_INFINITY;
82
+ let primed = false;
83
+ /**
84
+ * Each high-impact row not yet past its window, by id: null while blank,
85
+ * then its number. A number stays once seen, so a print that blanks and
86
+ * comes back is not a new print.
87
+ */
88
+ const actuals = new Map<string, string | null>();
89
+ const expiries = new Map<string, number>();
90
+ let attempt = 0;
91
+ let dueIds = new Set<string>();
92
+
93
+ const toast = (event: EconEvent) => {
94
+ ctx.notify({
95
+ title: event.country,
96
+ body: printBody(event),
97
+ type: "info",
98
+ desktop: "always",
99
+ sound: "Glass",
100
+ duration: TOAST_DURATION_MS,
101
+ refId: `econ:${event.id}`,
102
+ action: { label: "Open", onClick: () => ctx.showPane("econ-calendar") },
103
+ });
104
+ };
105
+
106
+ /** `now` is when the read was asked for, so a print the last ask in the window brings back still counts. */
107
+ const observe = (read: CalendarRead | null, now = deps.now()) => {
108
+ if (stopped || !read) return;
109
+ if (!latest || read.fetchedAt >= latest.fetchedAt) latest = read;
110
+ const landed = primed ? landedHighImpactPrints(actuals, read.data) : [];
111
+ for (const event of read.data) {
112
+ if (event.impact !== "high") continue;
113
+ const expiresAt = event.date.getTime() + PRINT_PENDING_MS;
114
+ if (expiresAt < now) continue;
115
+ expiries.set(event.id, expiresAt);
116
+ if (event.actual) actuals.set(event.id, event.actual);
117
+ else if (!actuals.has(event.id)) actuals.set(event.id, null);
118
+ }
119
+ for (const [id, expiresAt] of expiries) {
120
+ if (expiresAt >= now) continue;
121
+ expiries.delete(id);
122
+ actuals.delete(id);
123
+ }
124
+ primed = true;
125
+ // A print that turns up after its window is recorded, not announced late.
126
+ for (const event of landed) {
127
+ if (now - event.date.getTime() <= PRINT_PENDING_MS) toast(event);
128
+ }
129
+ };
130
+
131
+ /** Whether a print in the newest calendar is due and still within its window. */
132
+ const printDue = (now: number): boolean => {
133
+ const pending = latest ? pendingHighImpactPrints(latest.data, now) : [];
134
+ // A release that has just come due starts the waits over.
135
+ if (pending.length === 0 || pending.some((event) => !dueIds.has(event.id))) attempt = 0;
136
+ dueIds = new Set(pending.map((event) => event.id));
137
+ return pending.length > 0;
138
+ };
139
+
140
+ const backoff = (): number => {
141
+ const base = PRINT_BACKOFF_MS[Math.min(attempt, PRINT_BACKOFF_MS.length - 1)]!;
142
+ attempt += 1;
143
+ return Math.round(base * (1 + (deps.random() * 2 - 1) * PRINT_JITTER));
144
+ };
145
+
146
+ const askWhileIdle = (now: number): boolean => (latest
147
+ ? now - Math.max(latest.fetchedAt, lastAskAt) >= CALENDAR_RECHECK_MS
148
+ : now - lastAskAt >= NO_CALENDAR_RETRY_MS);
149
+
150
+ const arm = (delay: number) => {
151
+ if (stopped || cancel) return;
152
+ cancel = deps.schedule(() => {
153
+ cancel = null;
154
+ return run();
155
+ }, delay);
156
+ };
157
+
158
+ const run = async () => {
159
+ if (stopped || running) return;
160
+ observe(deps.read());
161
+ const before = deps.now();
162
+ const due = printDue(before);
163
+ if (!due && !askWhileIdle(before)) {
164
+ arm(PRINT_IDLE_MS);
165
+ return;
166
+ }
167
+ running = true;
168
+ lastAskAt = before;
169
+ try {
170
+ // A due print is asked for past the cache; otherwise a fresh cache answers without a request.
171
+ observe(await deps.load(due), before);
172
+ } catch {
173
+ // The next ask follows the same schedule.
174
+ } finally {
175
+ running = false;
176
+ }
177
+ if (stopped) return;
178
+ // Past the window nothing is due, so the asks stop and the watch goes back to idle looks.
179
+ arm(printDue(deps.now()) ? backoff() : PRINT_IDLE_MS);
180
+ };
181
+
182
+ // A load the watch is waiting on is compared when it answers, at the time it was asked.
183
+ const unsubscribe = deps.subscribe(() => {
184
+ if (!running) observe(deps.read());
185
+ });
186
+ void run();
187
+ return () => {
188
+ stopped = true;
189
+ unsubscribe();
190
+ cancel?.();
191
+ cancel = null;
192
+ };
193
+ }
194
+
195
+ type PrintNotificationContext = PrintWatchContext & Pick<GloomPluginContext, "configState" | "on">;
196
+
197
+ function printNotificationsEnabled(ctx: Pick<GloomPluginContext, "configState">): boolean {
198
+ return ctx.configState.get<boolean>(ECON_PRINT_NOTIFICATIONS_ENABLED_KEY) === true;
199
+ }
200
+
201
+ /**
202
+ * The one print watch, run while anything wants it: the Notifications
203
+ * setting, or an ECO pane in view, which fills a print in as it lands. Every
204
+ * reason shares its timing and its requests; only the setting toasts.
205
+ */
206
+ export interface PrintRefresh {
207
+ /** The plugin's context, for toasts and their Open; null once it is disposed. */
208
+ attach(ctx: PrintWatchContext | null): void;
209
+ setNotifying(on: boolean): void;
210
+ /** An ECO pane can be seen; the returned release says it no longer can. */
211
+ retainVisiblePane(): () => void;
212
+ }
213
+
214
+ export function createPrintRefresh(
215
+ start: (ctx: PrintWatchContext) => () => void = startHighImpactPrintWatch,
216
+ ): PrintRefresh {
217
+ let host: PrintWatchContext | null = null;
218
+ let notifying = false;
219
+ let visiblePanes = 0;
220
+ let stopWatch: (() => void) | null = null;
221
+ // A print that lands while only a pane wants the watch is recorded, not toasted.
222
+ const watchContext: PrintWatchContext = {
223
+ notify: (notice) => {
224
+ if (notifying) host?.notify(notice);
225
+ },
226
+ showPane: (paneId) => host?.showPane(paneId),
227
+ };
228
+ const sync = () => {
229
+ const wanted = notifying || visiblePanes > 0;
230
+ if (wanted && !stopWatch) stopWatch = start(watchContext);
231
+ if (!wanted && stopWatch) {
232
+ stopWatch();
233
+ stopWatch = null;
234
+ }
235
+ };
236
+ return {
237
+ attach(ctx) {
238
+ host = ctx;
239
+ },
240
+ setNotifying(on) {
241
+ notifying = on;
242
+ sync();
243
+ },
244
+ retainVisiblePane() {
245
+ visiblePanes += 1;
246
+ sync();
247
+ let released = false;
248
+ return () => {
249
+ if (released) return;
250
+ released = true;
251
+ visiblePanes -= 1;
252
+ sync();
253
+ };
254
+ },
255
+ };
256
+ }
257
+
258
+ const printRefresh = createPrintRefresh();
259
+
260
+ /** For the ECO pane: while it can be seen, a due high-impact print is asked for on the watch's timing. */
261
+ export function retainVisiblePrintRefresh(): () => void {
262
+ return printRefresh.retainVisiblePane();
263
+ }
264
+
265
+ /** Toasts prints while the Notifications setting is on; off, only a pane in view keeps the watch asking. */
266
+ export function setupHighImpactPrintNotifications(
267
+ ctx: PrintNotificationContext,
268
+ refresh: PrintRefresh = printRefresh,
269
+ ): () => void {
270
+ refresh.attach(ctx);
271
+ const sync = () => refresh.setNotifying(printNotificationsEnabled(ctx));
272
+ const disposeConfigListener = ctx.on("config:changed", sync);
273
+ sync();
274
+ return () => {
275
+ disposeConfigListener();
276
+ refresh.setNotifying(false);
277
+ refresh.attach(null);
278
+ };
279
+ }
@@ -1,10 +1,12 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import type { HeadlessPaneColumn, HeadlessPaneContext } from "../../../types/headless";
3
3
  import {
4
+ KPI_FIELDS,
4
5
  NUMERIC_FIELDS,
5
6
  type NumericField,
6
7
  type ScreenRow,
7
8
  } from "../../../api-client/equity-screener";
9
+ import { KPI_COVERAGE_NOTE } from "../company-kpis/model";
8
10
  import { EXTENDED_SESSION_LABELS, type ExtendedSession } from "../../../market-data/market/status";
9
11
  import { buildQuoteKey } from "../../../market-data/selectors";
10
12
  import type { Quote } from "../../../types/financials";
@@ -17,8 +19,11 @@ import {
17
19
  formatScreenValue,
18
20
  metricDate,
19
21
  parseScreenDefinition,
22
+ screenColumnLabel,
20
23
  screenDefinitionSyntax,
21
- SHORT_LABELS,
24
+ screenLabel,
25
+ screenYtdBase,
26
+ withheldMetricNote,
22
27
  } from "./model";
23
28
  import type { ScreenDefinition } from "../../../api-client/equity-screener";
24
29
 
@@ -128,12 +133,21 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
128
133
  // A sort on one of them is redone on those figures.
129
134
  const rows = sortLiveScreenRows(overlayScreenQuotes(data.rows, await loadRowQuotes(data.rows, ctx)), data.definition.sort);
130
135
  const extendedSessions = screenExtendedSessions(rows);
136
+ // A Pro metric the server held back from Free says so once, as KPIS says it; a KPI
137
+ // with gaps says what a gap is, as KPIS does.
138
+ const withheld = rows.some((row) => row.metrics[metric].access === "pro");
139
+ const errors = withheld ? [...data.warnings, withheldMetricNote(metric)] : data.warnings;
140
+ const missing = rows.filter((row) => row.metrics[metric].value === null).length;
141
+ const kpiGaps = !withheld && missing > 0 && (KPI_FIELDS as readonly string[]).includes(metric)
142
+ ? [`${missing} of ${rows.length} ${rows.length === 1 ? "row has" : "rows have"} no ${screenLabel(metric)}. ${KPI_COVERAGE_NOTE}`]
143
+ : [];
131
144
  // Payload status also counts ordinary per-field sparsity; only generation
132
145
  // warnings are failures, and more pages are not an error.
133
146
  return {
134
147
  freshness: { asOf: data.snapshot?.sourceNewestAt ?? null },
135
- complete: !data.nextCursor && data.warnings.length === 0,
136
- errors: data.warnings,
148
+ complete: !data.nextCursor && errors.length === 0,
149
+ errors,
150
+ ...(kpiGaps.length ? { notes: kpiGaps } : {}),
137
151
  sections: [
138
152
  {
139
153
  title: "Matching equities",
@@ -145,7 +159,8 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
145
159
  ...(metric === "price" ? [] : [{ key: "price", header: "Last", align: "right" as const, format: price }]),
146
160
  ...(metric === "changePercent" ? [] : [{ key: "changePercent", header: "Chg%", align: "right" as const, format: percent }]),
147
161
  ...extendedSessions.map(extendedColumn),
148
- { key: "value", header: SHORT_LABELS[metric], align: "right" },
162
+ // YTD names the close it is measured from: `YTD% vs 31 Dec close`.
163
+ { key: "value", header: screenColumnLabel(metric, screenYtdBase(data.rows, data.snapshot?.assembledAt), "report"), align: "right" },
149
164
  { key: "unit", header: "Unit" },
150
165
  { key: "percentile", header: "Covered pctl", align: "right" },
151
166
  { key: "asOf", header: "As of" },
@@ -172,7 +187,7 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
172
187
  ? null
173
188
  : Math.round(observation.percentile.value),
174
189
  asOf: stamp.collected ? `${stamp.text} (collected)` : stamp.text,
175
- state: observation.state,
190
+ state: observation.access === "pro" ? "locked" : observation.state,
176
191
  };
177
192
  }),
178
193
  },
@@ -17,9 +17,11 @@ import {
17
17
  type ScreenPayload,
18
18
  type ScreenRow,
19
19
  } from "../../../api-client/equity-screener";
20
+ import { yearToDateBaseLabel } from "../../../market-data/market/year-to-date";
20
21
  import { canonicalExchange } from "../../../utils/exchanges";
21
22
  import { LATEST_DISTRIBUTION, TRAILING_DIVIDEND_BASIS } from "../../../utils/forward-dividend";
22
23
  import { formatCompact, formatNumber } from "../../../utils/format";
24
+ import { kpiFreeLimitNote } from "../company-kpis/model";
23
25
 
24
26
  /**
25
27
  * Column headers. Currency sits in the footer; percentage fields carry %.
@@ -160,8 +162,29 @@ export function resultFields(definition: ScreenDefinition, metric: NumericField)
160
162
  return [...new Set(fields)].filter((field) => definition.currency || !MONETARY.has(field) || field === metric);
161
163
  }
162
164
 
165
+ /**
166
+ * The close the screen's YTD returns are measured from, `31 Dec`: NYSE's last
167
+ * session of the year before the newest session a row's YTD ends on, the
168
+ * snapshot's day without one.
169
+ */
170
+ export function screenYtdBase(rows: readonly ScreenRow[], assembledAt?: string | null, now = Date.now()): string | null {
171
+ const day = rows.map((row) => row.metrics.returnYtdPercent?.asOf?.slice(0, 10) ?? "").filter(Boolean).sort().at(-1)
172
+ ?? assembledAt?.slice(0, 10);
173
+ return day ? yearToDateBaseLabel(day, "NYSE", now) : null;
174
+ }
175
+
176
+ /**
177
+ * A column's header. YTD names the close it is measured from: `YTD% vs 31 Dec
178
+ * close` in a report, `YTD% vs 31 Dec` in the pane and `YTD% 31 Dec` where
179
+ * the pane is short of room.
180
+ */
181
+ export function screenColumnLabel(field: NumericField, ytdBase: string | null, form: "report" | "pane" | "compact" = "pane"): string {
182
+ if (field !== "returnYtdPercent" || !ytdBase) return SHORT_LABELS[field];
183
+ return form === "compact" ? `YTD% ${ytdBase}` : `YTD% vs ${ytdBase}${form === "report" ? " close" : ""}`;
184
+ }
185
+
163
186
  /** Prices get room for a separated six-figure quote (757,398.00). */
164
- export const columnWidth = (field: NumericField) => Math.max(field === "price" ? 10 : 8, SHORT_LABELS[field].length);
187
+ export const columnWidth = (field: NumericField, label = SHORT_LABELS[field]) => Math.max(field === "price" ? 10 : 8, label.length);
165
188
 
166
189
  export const DEFAULT_SCREEN: ScreenDefinition = {
167
190
  version: 1,
@@ -481,6 +504,20 @@ const unavailableMetric = (): ScreenMetric => ({
481
504
  state: "unavailable", scope: "reported", reason: "Not provided by this server", sourceUrl: null,
482
505
  percentile: { value: null, sampleCount: 0, scope: "covered-universe" },
483
506
  });
507
+ /** Company KPIs, guidance, hiring and app attention: the server leaves them out of a screen for a caller without Gloom Pro. */
508
+ const PRO_FIELDS: ReadonlySet<string> = new Set([...KPI_FIELDS, ...HIRING_APP_FIELDS]);
509
+ const withheldMetric = (): ScreenMetric => ({ ...unavailableMetric(), reason: "Needs Gloom Pro", access: "pro" });
510
+
511
+ /**
512
+ * What a screen says once of a metric the server held back from Free, a
513
+ * KPI's in the words `fn KPIS` uses ("The free screener shows no KPI values;
514
+ * Gloom Pro shows each company's Backlog.").
515
+ */
516
+ export function withheldMetricNote(field: NumericField): string {
517
+ return (KPI_FIELDS as readonly string[]).includes(field)
518
+ ? kpiFreeLimitNote({ metric: screenLabel(field), shown: 0, total: null, guidance: field.startsWith("guide"), where: "The free screener" })
519
+ : `${screenLabel(field)} needs Gloom Pro.`;
520
+ }
484
521
  export function validateScreenPayload(value: unknown): ScreenPayload {
485
522
  const data = value as ScreenPayload;
486
523
  if (
@@ -523,8 +560,9 @@ export function validateScreenPayload(value: unknown): ScreenPayload {
523
560
  ids.add(screenRowId(row));
524
561
  for (const field of NUMERIC_FIELDS) {
525
562
  // A server from before social, research, volatility or dividend fields omits them; they read as unavailable.
563
+ // A Pro field it left out was held back from this caller.
526
564
  if (!row.metrics[field] && ([...SOCIAL_FIELDS, ...RESEARCH_FIELDS, ...VOLATILITY_FIELDS, ...ATTENTION_FIELDS, ...HIRING_APP_FIELDS, ...KPI_FIELDS, ...DIVIDEND_FIELDS] as readonly string[]).includes(field))
527
- row.metrics[field] = unavailableMetric();
565
+ row.metrics[field] = PRO_FIELDS.has(field) ? withheldMetric() : unavailableMetric();
528
566
  const metric = row.metrics[field];
529
567
  if (
530
568
  !metric ||
@@ -67,10 +67,11 @@ import {
67
67
  metricDate,
68
68
  parseScreenDefinition,
69
69
  resultFields,
70
- SHORT_LABELS,
71
70
  SIGN_COLORED,
71
+ screenColumnLabel,
72
72
  screenLabel,
73
73
  screenRowId,
74
+ screenYtdBase,
74
75
  } from "./model";
75
76
  import { useScreenResults } from "./results";
76
77
  import { overlayLiveScreenRows, screenExtendedMove, screenExtendedSessions, sortLiveScreenRows, type LiveScreenRow } from "./live";
@@ -101,20 +102,19 @@ const EXTENDED_COLUMN_PREFIX = "ext:";
101
102
  * CHG% for each session in `extendedSessions`, and is added only whole, as
102
103
  * the metrics are.
103
104
  */
104
- function resultColumns(
105
+ export function resultColumns(
105
106
  width: number,
106
107
  definition: ScreenDefinition,
107
108
  metric: NumericField,
108
109
  asOf: boolean,
109
110
  extendedSessions: readonly ExtendedSession[] = [],
111
+ ytdBase: string | null = null,
110
112
  ): DataTableColumn[] {
111
113
  const [focus, ...others] = resultFields(definition, metric);
112
- const metricColumn = (field: NumericField): DataTableColumn => ({
113
- id: `m:${field}`,
114
- label: SHORT_LABELS[field],
115
- width: columnWidth(field),
116
- align: "right",
117
- });
114
+ const metricColumn = (field: NumericField, form: "pane" | "compact"): DataTableColumn => {
115
+ const label = screenColumnLabel(field, ytdBase, form);
116
+ return { id: `m:${field}`, label, width: columnWidth(field, label), align: "right" };
117
+ };
118
118
  const extendedColumns = extendedSessions.map((session): DataTableColumn => ({
119
119
  id: `${EXTENDED_COLUMN_PREFIX}${session}`,
120
120
  label: extendedSessionStateLabel(session),
@@ -122,20 +122,21 @@ function resultColumns(
122
122
  align: "right",
123
123
  }));
124
124
  // The extended move reads beside CHG%, or first after the focus metric when that is CHG%.
125
- const extras = [
125
+ const extras = (form: "pane" | "compact") => [
126
126
  ...(focus === "changePercent" ? extendedColumns : []),
127
- ...others.flatMap((field) => field === "changePercent" ? [metricColumn(field), ...extendedColumns] : [metricColumn(field)]),
127
+ ...others.flatMap((field) => field === "changePercent" ? [metricColumn(field, form), ...extendedColumns] : [metricColumn(field, form)]),
128
128
  ];
129
129
  const build = (
130
130
  extra: DataTableColumn[],
131
131
  exchange: boolean,
132
132
  sector: boolean,
133
+ form: "pane" | "compact",
133
134
  ): DataTableColumn[] => [
134
135
  { id: "symbol", label: "SYMBOL", width: 8, align: "left" },
135
136
  { id: "name", label: "NAME", width: 18, flexGrow: 1, align: "left" },
136
137
  ...(exchange ? [{ id: "exchange", label: "EXCH", width: 7, align: "left" as const }] : []),
137
138
  ...(sector ? [{ id: "sector", label: "SECTOR", width: 22, align: "left" as const }] : []),
138
- metricColumn(focus!),
139
+ metricColumn(focus!, form),
139
140
  { id: "percentile", label: "PCTL", width: 4, align: "right" },
140
141
  ...(asOf ? [{ id: "date", label: "AS OF", width: 10, align: "left" as const }] : []),
141
142
  ...extra,
@@ -143,15 +144,15 @@ function resultColumns(
143
144
  // Measured the way the table draws them (header floor, gaps, lead gaps), so a
144
145
  // column is only added when it fits whole.
145
146
  const fits = (columns: DataTableColumn[]) => getTableWidth(columns) <= width;
146
- let shown: DataTableColumn[] = [];
147
- for (const column of extras) {
148
- const next = [...shown, column];
149
- if (!fits(build(next, false, false))) break;
150
- shown = next;
151
- }
152
- const exchange = fits(build(shown, true, false));
153
- const sector = exchange && fits(build(shown, true, true));
154
- return build(shown, exchange, sector);
147
+ // Columns are chosen with YTD's short header (`YTD% 31 Dec`), which reads
148
+ // `YTD% vs 31 Dec` when that still fits: naming the close never costs a column.
149
+ const compact = extras("compact");
150
+ let shown = 0;
151
+ while (shown < compact.length && fits(build(compact.slice(0, shown + 1), false, false, "compact"))) shown++;
152
+ const exchange = fits(build(compact.slice(0, shown), true, false, "compact"));
153
+ const sector = exchange && fits(build(compact.slice(0, shown), true, true, "compact"));
154
+ const named = build(extras("pane").slice(0, shown), exchange, sector, "pane");
155
+ return fits(named) ? named : build(compact.slice(0, shown), exchange, sector, "compact");
155
156
  }
156
157
  const loadFields = () => fetchScreenFields();
157
158
  function ScreenDetail({
@@ -351,7 +352,9 @@ function EquityScreenView({
351
352
  const staleFocus = snapshotRows.some((row) => row.metrics[metric]?.state === "stale");
352
353
  // A pre-market, after-hours or overnight column only while a row has such a print.
353
354
  const extendedSessions = screenExtendedSessions(rows);
354
- const columns = resultColumns(width, definition, metric, staleFocus, extendedSessions);
355
+ // YTD names the close it is measured from, `31 Dec`.
356
+ const ytdBase = screenYtdBase(snapshotRows, data?.snapshot?.assembledAt);
357
+ const columns = resultColumns(width, definition, metric, staleFocus, extendedSessions, ytdBase);
355
358
  const apply = (next: ScreenDefinition) => {
356
359
  try {
357
360
  setDefinition(parseScreenDefinition(next));
@@ -45,10 +45,11 @@ export const estimateRevisionsHeadless: HeadlessPaneDefinition<"bundle"> = {
45
45
  instrument?.exchange ?? "",
46
46
  ctx.apiClient,
47
47
  ctx.marketData,
48
- ).catch((error: unknown) => {
49
- throw explainRefusal(error, estimateRevisionsLockNote, {
48
+ ).catch(async (error: unknown) => {
49
+ throw await explainRefusal(error, estimateRevisionsLockNote, {
50
50
  symbol: args.symbols[0] ?? null,
51
51
  token: ctx.settings?.tab === "guidance" ? "GUID" : "EM",
52
+ context: ctx,
52
53
  });
53
54
  });
54
55
  const periods = pinnedEstimatePeriods(
@@ -1,13 +1,37 @@
1
1
  import type { PaneLock } from "../../../types/plugin";
2
- import { proLockNote } from "../shared/pro-lock-note";
2
+ import { fetchCompanyData } from "../company-kpis/client";
3
+ import { filesForm } from "../shared/free-routes";
4
+ import { resolveHeadlessIssuerListing } from "../shared/headless-market-data";
5
+ import { proLockNote, type FreeRoute } from "../shared/pro-lock-note";
6
+
7
+ /** GUIDE's free preview, where it holds guidance for the company: Apple issues none, so GUIDE has no AAPL rows. */
8
+ const GUIDE_ROUTE: FreeRoute = {
9
+ text: (ticker, company) => `gloomberb fn GUIDE ${ticker} lists ${company}'s latest guidance ranges, with the date each was issued.`,
10
+ answers: async (symbol, context) => {
11
+ const data = await fetchCompanyData("guidance", symbol, {}, context.apiClient, await resolveHeadlessIssuerListing(context, symbol), context.signal);
12
+ return "guidance" in data && data.guidance.length + data.history.length > 0;
13
+ },
14
+ };
15
+
16
+ /** The company's 8-Ks, where results and any guidance it gives are filed, for a company that files them. */
17
+ const EIGHT_K_ROUTE: FreeRoute = {
18
+ text: (ticker, company) => `gloomberb filings ${ticker} --form 8-K lists ${company}'s 8-Ks, where it files its results and any guidance, each with its items and a link to the filing.`,
19
+ answers: (symbol, context) => filesForm(symbol, "8-K", context),
20
+ };
21
+
22
+ const EARNINGS_ROUTE: FreeRoute = {
23
+ text: (ticker, company) => `gloomberb earnings ${ticker} lists ${company}'s next report date with its consensus EPS estimate.`,
24
+ answers: async (symbol, context) => (await context.marketData.getEarningsCalendar?.([symbol]) ?? []).length > 0,
25
+ };
3
26
 
4
27
  /**
5
28
  * EM, EEO and GUID read one Gloom Cloud route that asks for a signed-in account
6
29
  * with a verified email, and no plan. The report names the function it was run
7
- * for. GUIDE lists a company's latest guidance ranges, as a free preview;
8
- * `earnings` lists the next report date with its consensus EPS estimate.
30
+ * for. GUIDE lists a company's latest guidance ranges, as a free preview, and
31
+ * its 8-Ks hold what GUIDE does not cover; `earnings` lists the next report
32
+ * date with its consensus EPS estimate.
9
33
  */
10
- export function estimateRevisionsLockNote(lock: PaneLock): string {
34
+ export function estimateRevisionsLockNote(lock: PaneLock): Promise<string> {
11
35
  const guidance = lock.token === "GUID";
12
36
  return proLockNote(lock, {
13
37
  token: guidance ? "GUID" : "EM",
@@ -16,8 +40,6 @@ export function estimateRevisionsLockNote(lock: PaneLock): string {
16
40
  ? "it reads the EPS guidance a company issued, cited from its filings and calls, against consensus"
17
41
  : "it reads consensus EPS estimates by fiscal period, how they were revised, the surprises against reported EPS and the guidance",
18
42
  shows: "it",
19
- free: guidance
20
- ? (ticker, company) => `gloomberb fn GUIDE ${ticker} lists ${company}'s latest guidance ranges, with the date each was issued.`
21
- : (ticker, company) => `gloomberb earnings ${ticker} lists ${company}'s next report date with its consensus EPS estimate.`,
43
+ free: guidance ? [GUIDE_ROUTE, EIGHT_K_ROUTE] : [EARNINGS_ROUTE],
22
44
  });
23
45
  }
@@ -21,7 +21,10 @@ export function validateExposure(value: ExposurePayload): ExposurePayload {
21
21
  && typeof data.evaluatedAt === "string" && Number.isFinite(Date.parse(data.evaluatedAt))
22
22
  && Number.isInteger(data.requestedHoldings) && Number.isInteger(data.lockedHoldings) && data.lockedHoldings >= 0
23
23
  && Array.isArray(data.holdings) && strings(data.unknowns) && strings(data.methodology);
24
- try { parseCustomScenario(JSON.stringify(data.scenario)); } catch { valid = false; }
24
+ try { parseCustomScenario(JSON.stringify(data.scenario)); } catch {
25
+ // Intentional ignore: invalid scenarios use the payload validation error below.
26
+ valid = false;
27
+ }
25
28
  valid &&= data.holdings.every(h => typeof h.symbol === "string" && finite(h.weight) && Array.isArray(h.unknowns)
26
29
  && ["quantified", "partial", "unknown"].includes(h.status) && !!h.coverage
27
30
  && Array.isArray(h.measures) && h.measures.every(m => validRange(m.exposurePct) && nullableRange(m.impactPct) && strings(m.componentIds))
@@ -7,13 +7,13 @@ import type { ExposureScenario } from "../../../api-client/exposure";
7
7
  import { parseCustomScenario } from "./model";
8
8
 
9
9
  export interface ExposureInputs { input: string; source: string; nav: string; cash: string; depth: number; scenario: ExposureScenario; }
10
- export function ExposureEditor({ initial, sources, focused, width, onSave, onCancel }: {
11
- initial: ExposureInputs; sources: { value: string; label: string }[]; focused: boolean; width: number;
10
+ export function ExposureEditor({ initial, initialCustom, sources, focused, width, onSave, onCancel }: {
11
+ initial: ExposureInputs; initialCustom?: string; sources: { value: string; label: string }[]; focused: boolean; width: number;
12
12
  onSave: (value: ExposureInputs) => void; onCancel: () => void;
13
13
  }) {
14
14
  const [draft, setDraft] = useState(initial);
15
15
  const first = initial.scenario.shocks[0]!;
16
- const [custom, setCustom] = useState(initial.scenario.shocks.length > 1 || !!first.transmission ? JSON.stringify(initial.scenario) : "");
16
+ const [custom, setCustom] = useState(initialCustom ?? (initial.scenario.shocks.length > 1 || !!first.transmission ? JSON.stringify(initial.scenario) : ""));
17
17
  const [label, setLabel] = useState(initial.scenario.label);
18
18
  const [kind, setKind] = useState(first.kind);
19
19
  const [target, setTarget] = useState(first.target);
@@ -26,7 +26,10 @@ export const exposureScreenshotEvidence: PaneScreenshotEvidenceHook<ExposureCapt
26
26
  const rows = metadata.tab === "drivers" ? driverRows(payload) : metadata.tab === "paths" ? pathRows(payload) : metadata.tab === "portfolio" ? portfolioRows(payload, String(metadata.view)) : tableRows(payload);
27
27
  if (metadata.plottedValueCount !== rows.length || metadata.rowIds.length !== rows.length || metadata.rowIds.some(id => !rows.some(r => r.id === id))) return null;
28
28
  return metadata as unknown as ExposureCapture;
29
- } catch { return null; }
29
+ } catch {
30
+ // Intentional ignore: malformed capture metadata cannot prove the screenshot's exposure data.
31
+ return null;
32
+ }
30
33
  },
31
34
  mismatches(evidence, { resolved, payload }) {
32
35
  const snapshot = payload.config.layout.instances.find(i => i.instanceId === payload.paneId)?.settings?.exposureSnapshot;
@@ -4,6 +4,7 @@ import type { Portfolio, TickerRecord } from "../../../types/ticker";
4
4
  import { publicTickerKey } from "../../../utils/exchanges";
5
5
  import { resolveCurrencyUnit } from "../../../utils/currency-units";
6
6
  import { convertCurrency } from "../../../utils/format";
7
+ import { logFailure } from "../../../utils/log-failure";
7
8
  import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolioMarketValue } from "../../../portfolio/position-metrics";
8
9
 
9
10
  /** Explicit NAV preserves cash, shorts and leverage; net holdings are never used as NAV. */
@@ -35,7 +36,8 @@ export async function portfolioHoldings(portfolio: Portfolio, tickers: TickerRec
35
36
  for (const currency of currencies) await ensureRate(currency);
36
37
  const holdings: ExposureHolding[] = [];
37
38
  for (const ticker of positions) {
38
- const quote = await market.getQuote(ticker.metadata.ticker, ticker.metadata.exchange).catch(() => null);
39
+ const quote = await market.getQuote(ticker.metadata.ticker, ticker.metadata.exchange)
40
+ .catch(logFailure("exposure.holdings", "Load portfolio holding quote failed", null));
39
41
  const currency = quote?.currency || ticker.metadata.currency;
40
42
  if (!rates.has(currency)) await ensureRate(currency);
41
43
  const metrics = getPortfolioPositionMetrics(ticker, portfolio.id, currency, {