gloomberb 0.17.0 → 0.17.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 (201) hide show
  1. package/package.json +1 -1
  2. package/src/{plugins/builtin/cloud → account}/auth-dialog.tsx +7 -7
  3. package/src/api-client/data.ts +5 -1
  4. package/src/api-client/doe.ts +19 -0
  5. package/src/api-client/earnings.ts +4 -2
  6. package/src/api-client/index.ts +1 -0
  7. package/src/app/global-shortcuts.ts +10 -0
  8. package/src/app/keybindings/actions.ts +16 -0
  9. package/src/app/runtime/desktop-deeplink.ts +1 -1
  10. package/src/app/runtime/update.ts +16 -0
  11. package/src/app.tsx +1 -1
  12. package/src/architecture/core-builtin-allowlist.json +4 -9
  13. package/src/architecture/dom-cell-metrics-allowlist.json +100 -26
  14. package/src/cli/commands/market.ts +87 -17
  15. package/src/cli/commands/options-chain.ts +93 -23
  16. package/src/cli/commands/options-leaps.ts +462 -63
  17. package/src/cli/commands/search.ts +10 -7
  18. package/src/cli/commands/ticker.ts +19 -10
  19. package/src/cli/glossary.ts +1 -1
  20. package/src/cli/index.ts +14 -6
  21. package/src/cli/known-gaps.ts +22 -0
  22. package/src/cli/pane-functions/catalog.ts +45 -10
  23. package/src/cli/pane-functions/dom.ts +36 -10
  24. package/src/cli/pane-functions/function-help.ts +4 -2
  25. package/src/cli/pane-functions/headless.ts +12 -1
  26. package/src/cli/pane-functions/index.ts +44 -3
  27. package/src/cli/pane-functions/lock-note.ts +56 -0
  28. package/src/cli/pane-functions/report-notices.ts +15 -0
  29. package/src/cli/symbol-args.ts +19 -0
  30. package/src/components/command-bar/list/view.tsx +2 -1
  31. package/src/components/command-bar/workflow/builtin.ts +2 -2
  32. package/src/components/command-bar/workflow/collection-actions.ts +1 -1
  33. package/src/components/feedback-dialog.tsx +1 -1
  34. package/src/components/index.ts +3 -2
  35. package/src/components/layout/header.tsx +58 -14
  36. package/src/components/layout/shell/chrome.ts +10 -4
  37. package/src/components/layout/shell/index.tsx +1 -1
  38. package/src/components/layout/status-bar-groups.ts +1 -1
  39. package/src/components/layout/status-bar.tsx +2 -2
  40. package/src/components/markdown-text.tsx +14 -2
  41. package/src/components/reader/article-reader.tsx +1 -1
  42. package/src/components/reader/detail-reader.tsx +10 -7
  43. package/src/components/reader/reader-text.tsx +32 -17
  44. package/src/components/ui/column-grid.tsx +206 -0
  45. package/src/components/ui/display.tsx +40 -3
  46. package/src/components/ui/fill.ts +8 -0
  47. package/src/components/ui/index.ts +2 -0
  48. package/src/layout-marketplace/gallery.tsx +2 -2
  49. package/src/layout-marketplace/linked-sync.ts +1 -1
  50. package/src/market-data/market/freshness.ts +3 -0
  51. package/src/market-data/published-apac-sessions.ts +12 -2
  52. package/src/market-data/quotes/screener-live-quotes.ts +31 -7
  53. package/src/plugins/builtin/account-management/form-components.tsx +4 -7
  54. package/src/plugins/builtin/account-management/password-dialog.tsx +3 -2
  55. package/src/plugins/builtin/backtest/pane.tsx +9 -3
  56. package/src/plugins/builtin/buildout/detail/company.tsx +4 -3
  57. package/src/plugins/builtin/buildout/detail/ui.tsx +9 -12
  58. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  59. package/src/plugins/builtin/chart-composer/presets.ts +33 -1
  60. package/src/plugins/builtin/chat/channel-labels.ts +2 -2
  61. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
  62. package/src/plugins/builtin/chat/content/index.tsx +2 -2
  63. package/src/plugins/builtin/chat/conversations.ts +1 -1
  64. package/src/plugins/builtin/chat/index.tsx +2 -2
  65. package/src/plugins/builtin/chat/share/flow.tsx +1 -1
  66. package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
  67. package/src/plugins/builtin/chat/sidebar.tsx +2 -2
  68. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
  69. package/src/plugins/builtin/cloud/askg/pane.tsx +2 -1
  70. package/src/plugins/builtin/cloud/askg/tool-timeline.tsx +3 -2
  71. package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
  72. package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
  73. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
  74. package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
  75. package/src/plugins/builtin/cloud/team/capability.ts +2 -2
  76. package/src/plugins/builtin/cloud/team/command.ts +4 -4
  77. package/src/plugins/builtin/cloud/team/module.tsx +3 -3
  78. package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
  79. package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
  80. package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
  81. package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
  82. package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
  83. package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
  84. package/src/plugins/builtin/cloud/team/views.ts +2 -2
  85. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
  86. package/src/plugins/builtin/cloud/thesis/detail.tsx +12 -10
  87. package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
  88. package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
  89. package/src/plugins/builtin/congress-trades/detail.tsx +8 -4
  90. package/src/plugins/builtin/connections/pane.tsx +16 -5
  91. package/src/plugins/builtin/cpi/pane.tsx +5 -4
  92. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  93. package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
  94. package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
  95. package/src/plugins/builtin/docs/blocks.tsx +13 -2
  96. package/src/plugins/builtin/docs/pane.tsx +6 -5
  97. package/src/plugins/builtin/doe/index.tsx +11 -4
  98. package/src/plugins/builtin/doe/pane.tsx +5 -4
  99. package/src/plugins/builtin/doe/prices.ts +84 -0
  100. package/src/plugins/builtin/doe/series.ts +4 -1
  101. package/src/plugins/builtin/earnings/board-model.ts +18 -1
  102. package/src/plugins/builtin/earnings/board-table.ts +18 -3
  103. package/src/plugins/builtin/earnings/headless.ts +13 -2
  104. package/src/plugins/builtin/earnings/history-model.ts +1 -1
  105. package/src/plugins/builtin/earnings/history-table.ts +2 -2
  106. package/src/plugins/builtin/earnings-calls/headless.ts +7 -21
  107. package/src/plugins/builtin/earnings-calls/index.tsx +2 -0
  108. package/src/plugins/builtin/earnings-calls/lock-note.ts +17 -0
  109. package/src/plugins/builtin/equity-screener/headless.ts +59 -1
  110. package/src/plugins/builtin/equity-screener/live.ts +82 -31
  111. package/src/plugins/builtin/equity-screener/pane.tsx +60 -23
  112. package/src/plugins/builtin/estimate-revisions/client.ts +1 -1
  113. package/src/plugins/builtin/estimate-revisions/headless.ts +9 -2
  114. package/src/plugins/builtin/estimate-revisions/index.tsx +3 -0
  115. package/src/plugins/builtin/estimate-revisions/lock-note.ts +23 -0
  116. package/src/plugins/builtin/executives/index.tsx +18 -0
  117. package/src/plugins/builtin/exposure/charts.tsx +4 -3
  118. package/src/plugins/builtin/filing-events/index.tsx +8 -10
  119. package/src/plugins/builtin/fixed-income/pane.tsx +2 -1
  120. package/src/plugins/builtin/gpu/equities.tsx +9 -6
  121. package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
  122. package/src/plugins/builtin/iv-history/format.ts +3 -0
  123. package/src/plugins/builtin/iv-history/headless.ts +3 -1
  124. package/src/plugins/builtin/iv-history/index.tsx +3 -1
  125. package/src/plugins/builtin/iv-history/model.ts +3 -1
  126. package/src/plugins/builtin/iv-history/screen-pane.tsx +3 -1
  127. package/src/plugins/builtin/iv-history/universe.ts +1 -1
  128. package/src/plugins/builtin/jobs/headless.ts +6 -20
  129. package/src/plugins/builtin/jobs/index.tsx +2 -0
  130. package/src/plugins/builtin/jobs/lock-note.ts +16 -0
  131. package/src/plugins/builtin/notes/index.tsx +1 -1
  132. package/src/plugins/builtin/notes/owner.tsx +2 -2
  133. package/src/plugins/builtin/options/carry.ts +36 -0
  134. package/src/plugins/builtin/options/index.tsx +2 -1
  135. package/src/plugins/builtin/options/model-spot.ts +94 -0
  136. package/src/plugins/builtin/options/open-interest-as-of.ts +74 -0
  137. package/src/plugins/builtin/options/view.tsx +69 -11
  138. package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
  139. package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
  140. package/src/plugins/builtin/options-scenario/client.ts +33 -9
  141. package/src/plugins/builtin/options-scenario/csp.ts +168 -0
  142. package/src/plugins/builtin/options-scenario/evidence.ts +3 -0
  143. package/src/plugins/builtin/options-scenario/headless.ts +24 -8
  144. package/src/plugins/builtin/options-scenario/index.tsx +3 -1
  145. package/src/plugins/builtin/options-scenario/pane.tsx +32 -6
  146. package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
  147. package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
  148. package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
  149. package/src/plugins/builtin/research/event-model.ts +7 -4
  150. package/src/plugins/builtin/risk-factors/index.tsx +18 -0
  151. package/src/plugins/builtin/risk-factors/pane.tsx +2 -1
  152. package/src/plugins/builtin/scanner/headless.ts +9 -2
  153. package/src/plugins/builtin/scanner/index.ts +3 -0
  154. package/src/plugins/builtin/scanner/lock-note.ts +17 -0
  155. package/src/plugins/builtin/series-inventory.ts +2 -1
  156. package/src/plugins/builtin/shared/cloud-denied.ts +39 -8
  157. package/src/plugins/builtin/shared/earnings-report.ts +10 -4
  158. package/src/plugins/builtin/shared/pro-lock-note.ts +58 -0
  159. package/src/plugins/builtin/shared/series-list-detail.tsx +10 -5
  160. package/src/plugins/builtin/social-mentions/client.ts +3 -3
  161. package/src/plugins/builtin/social-mentions/headless.ts +6 -2
  162. package/src/plugins/builtin/social-mentions/index.tsx +2 -1
  163. package/src/plugins/builtin/social-mentions/lock-note.ts +15 -0
  164. package/src/plugins/builtin/supply-chain/flow.tsx +15 -13
  165. package/src/plugins/builtin/supply-chain/graph.tsx +6 -4
  166. package/src/plugins/builtin/supply-chain/pane.tsx +8 -5
  167. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +14 -1
  168. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +9 -7
  169. package/src/plugins/builtin/ticker-detail/headless.ts +4 -1
  170. package/src/plugins/builtin/ticker-detail/overview/components.tsx +105 -104
  171. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -2
  172. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +34 -8
  173. package/src/plugins/builtin/vol-surface/pane.tsx +6 -3
  174. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  175. package/src/renderers/dom/data-table/index.tsx +48 -15
  176. package/src/renderers/dom/data-table/row.tsx +1 -1
  177. package/src/renderers/dom/desktop/popover.tsx +6 -15
  178. package/src/renderers/dom/host/input.tsx +27 -3
  179. package/src/renderers/dom/host/surface-3d.tsx +6 -4
  180. package/src/renderers/dom/styles.css +26 -6
  181. package/src/renderers/electrobun/view/app-services.ts +2 -22
  182. package/src/renderers/electrobun/view/news-source.ts +42 -0
  183. package/src/series/validate.ts +5 -0
  184. package/src/shares/dialog.tsx +2 -1
  185. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  186. package/src/sources/provider-router/financials.ts +5 -0
  187. package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
  188. package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
  189. package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
  190. package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
  191. package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
  192. package/src/types/data-provider.ts +3 -0
  193. package/src/types/financials.ts +28 -0
  194. package/src/types/plugin.ts +17 -4
  195. package/src/utils/decimal-comma.ts +18 -0
  196. package/src/utils/exchanges.ts +7 -0
  197. package/src/utils/financial-statements.ts +25 -0
  198. package/src/utils/forward-dividend.ts +39 -9
  199. package/src/utils/fundamentals.ts +41 -1
  200. package/src/utils/option-expiry.ts +16 -0
  201. package/src/utils/sec.ts +2 -1
@@ -27,7 +27,9 @@ export const optionsScenarioModule: PluginModule = {
27
27
  paneTemplates: [createTickerSurfacePaneTemplate({
28
28
  id: "options-scenario-pane", paneId: "options-scenario", label: "Options Scenario",
29
29
  description: "Build multi-leg positions and compare scenario P&L, payoff and aggregate Greeks.",
30
- keywords: ["osa", "options", "scenario", "payoff", "strategy", "greeks", "hedge", "hedging", "collar", "protective put", "put spread"],
30
+ // OSA owns the phrases; OMON and VCA carry the same words without them, so a put-selling search finds all three, OSA first.
31
+ keywords: ["osa", "options", "scenario", "payoff", "strategy", "greeks", "hedge", "hedging", "collar", "protective put", "put spread",
32
+ "cash secured put", "csp", "put selling", "sell put", "short put", "wheel"],
31
33
  shortcut: "OSA", publicShare: true,
32
34
  settings: (symbol, _context, options) => ({ symbol, ...Object.fromEntries(scenarioSettingKeys
33
35
  .filter((key) => options?.values?.[key] != null).map((key) => [key, options!.values![key]])) }),
@@ -21,7 +21,10 @@ import { buildOptionQuoteKey, freshOptionQuote, OPTIONS_QUOTE_EXCHANGE } from ".
21
21
  import { liveScenarioPosition, scenarioLegContractSymbol } from "./live";
22
22
  import { ScenarioPayoffChart } from "./charts";
23
23
  import { ScenarioLegEditor, ScenarioSaveForm, ScenarioInputsForm } from "./editor";
24
- import { loadScenarioMarket, scenarioControlsFromSettings, scenarioPositionFromSettings, type ScenarioMarketSnapshot } from "./client";
24
+ import { loadScenarioMarket, scenarioControlsFromSettings, scenarioPositionFromSettings, scenarioSpotBasis, type ScenarioMarketSnapshot } from "./client";
25
+ import { cashSecuredPut, cspStatItems, loadNextEarningsDate } from "./csp";
26
+ import { useAssetData } from "../../runtime";
27
+ import { formatSpotTime } from "../options/model-spot";
25
28
  import { buildScenario, currencyLabel, scenarioCurrency, scenarioValueUnit, UNKNOWN_CURRENCY, type ScenarioLeg, type ScenarioPosition,
26
29
  type ScenarioControls, validatePosition } from "./model";
27
30
  import { EMPTY_SAVED_STRATEGIES, restoreSavedStrategies, type SavedScenarioStrategy } from "./state";
@@ -141,6 +144,19 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
141
144
  }, [frozen, position, controls, seeded.error, controlState, baseControls.error]);
142
145
  const scenario = result.scenario;
143
146
  const error = localError ?? result.error;
147
+ // A cash-secured put reads the earnings calendar for whether a report falls before it expires.
148
+ const assetData = useAssetData();
149
+ const cspStrategy = settings.strategy === "csp";
150
+ const earningsLoader = useCallback(() => loadNextEarningsDate(assetData, underlying), [assetData, underlying]);
151
+ // A screenshot keeps the date its report read.
152
+ const frozenEarnings = Object.hasOwn(settings, "scenarioEarningsDate");
153
+ const earnings = useAsyncResource(cspStrategy && underlying && !frozenEarnings ? earningsLoader : null);
154
+ const earningsDate = frozenEarnings ? settings.scenarioEarningsDate as string | null : earnings.data;
155
+ // A position following the market is at the live print, which the footer names with its feed.
156
+ const spotBasis = scenario && !live ? scenarioSpotBasis(settings, scenario.position, market) : null;
157
+ const csp = useMemo(() => cspStrategy && scenario && !earnings.loading
158
+ ? cashSecuredPut(scenario.position, live?.basis ?? spotBasis ?? "last price", earningsDate) : null,
159
+ [cspStrategy, scenario, earnings.loading, earningsDate, live?.basis, spotBasis]);
144
160
  // The report's Hedge budget, from the same position and quotes; a live position sizes at its live quotes and spot.
145
161
  const hedge = useMemo(() => {
146
162
  if (!scenario) return { value: null, error: null };
@@ -230,13 +246,19 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
230
246
  && money(market.spot) !== money(position!.spot) ? ` · last ${money(market.spot)}` : "";
231
247
  const midVolatility = !midLegs ? "" : (midLegs === position!.legs.length ? " · IV from quote mid"
232
248
  : ` · IV from quote mid on ${midLegs} of ${position!.legs.length} legs`) + lastPrint;
249
+ // The spot every figure is valued at, and when: the live print, the spot the quote mids imply, a typed what-if or the last price.
250
+ const spotText = position ? `spot ${money(position.spot)}${spotBasis && spotBasis !== "last price" ? ` ${spotBasis}` : ""}` : "";
251
+ // A narrow row keeps the spot and its time before the rest.
252
+ const shortSpotText = position ? `spot ${money(position.spot)} · ${formatSpotTime(live?.observedAt ?? position.asOf)}` : "";
233
253
  usePaneFooter("osa", () => ({ info: [
234
254
  ...(resource.loading ? [{ id: "loading", parts: [{ text: "loading chain", tone: "muted" as const }] }] : []),
235
255
  ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []),
236
256
  ...(hedge.error ? [{ id: "hedge-error", parts: [{ text: hedge.error, tone: "warning" as const }] }] : []),
237
- ...(live ? [{ id: "asof", parts: [{ text: `${live.basis} · as of ${new Date(live.observedAt).toISOString().slice(11, 19)} UTC`, tone: "muted" as const }] }]
238
- : position ? [{ id: "asof", parts: [{ text: `${dateLabel(position.asOf)} · ${market?.source ? "market" : "input assumptions"}${midVolatility}`, tone: "muted" as const }] }] : []),
239
- ], hints }), [hints, position, market?.source, resource.loading, error, hedge.error, live?.basis, live?.observedAt, midVolatility]);
257
+ ...(live ? [{ id: "asof", parts: [{ text: `${live.basis} · ${spotText} · ${new Date(live.observedAt).toISOString().slice(11, 19)} UTC`, tone: "muted" as const }],
258
+ variants: [[{ text: shortSpotText, tone: "muted" as const }]] }]
259
+ : position ? [{ id: "asof", parts: [{ text: `${spotText} · ${formatSpotTime(position.asOf)} · ${market?.source ? "market" : "input assumptions"}${midVolatility}`, tone: "muted" as const }],
260
+ variants: [[{ text: shortSpotText, tone: "muted" as const }]] }] : []),
261
+ ], hints }), [hints, position, market?.source, resource.loading, error, hedge.error, live?.basis, live?.observedAt, midVolatility, spotText, shortSpotText]);
240
262
  useScenarioEvidence({ scenario, view: tab, loading: !!resource.loading && !scenario, error: error ?? (snapshotErrors.join("; ") || null), notices });
241
263
  // A choice dialog (scenario date, saved strategies) owns the keys while open.
242
264
  const dialogOpen = useDialogState((state) => state.isOpen);
@@ -262,7 +284,9 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
262
284
  const stats: StatItem[] = scenario ? [
263
285
  { id: "pnl", label: "P&L", value: money(scenario.valuation.pnl), detail: valueUnit,
264
286
  tone: cents(scenario.valuation.pnl) >= 0 ? "positive" : "negative" },
265
- { id: "spot", label: "Spot", value: money(scenario.position.spot), detail: "per share" },
287
+ // A spot other than the last price says what it is: the one the quote mids imply, or a typed what-if.
288
+ { id: "spot", label: "Spot", value: money(scenario.position.spot),
289
+ detail: spotBasis === "implied by option quotes" ? "per share, implied" : spotBasis === "typed" ? "per share, typed" : "per share" },
266
290
  { id: "value", label: "Value", value: money(scenario.valuation.price), detail: valueUnit },
267
291
  { id: "max-profit", label: "Max profit", value: risk?.unlimitedProfit ? "Unlimited" : money(risk?.maxProfit ?? null) },
268
292
  { id: "max-loss", label: "Max loss", value: risk?.unlimitedLoss ? "Unlimited" : money(risk?.maxLoss ?? null) },
@@ -274,9 +298,10 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
274
298
  { id: "rho", label: "Rho", value: money(scenario.valuation.rhoPerPoint), detail: "per pt" },
275
299
  ] : [];
276
300
  const hedgeItems = hedge.value ? hedgeStatItems(hedge.value) : [];
301
+ const cspItems = csp ? cspStatItems(csp) : [];
277
302
  // The query bar and the stat band sit above the content only once a scenario prices.
278
303
  const bodyHeight = Math.max(3, height - tabRows - (scenario ? 1 + statGridRows(stats, width) : 0)
279
- - (hedgeItems.length ? statGridRows(hedgeItems, width) : 0));
304
+ - (hedgeItems.length ? statGridRows(hedgeItems, width) : 0) - (cspItems.length ? statGridRows(cspItems, width) : 0));
280
305
  const legColumns: DataTableColumn[] = [{ id: "quantity", label: "Contracts", width: 10, align: "right" },
281
306
  { id: "side", label: "Option", width: 7, align: "left" }, { id: "strike", label: "Strike", width: 11, align: "right" },
282
307
  { id: "expiration", label: "Expiry", width: 12, align: "left" }, { id: "price", label: "Entry / share", width: 14, align: "right" },
@@ -332,6 +357,7 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
332
357
  />
333
358
  <StatGrid items={stats} width={width} />
334
359
  {hedgeItems.length > 0 && <StatGrid items={hedgeItems} width={width} />}
360
+ {cspItems.length > 0 && <StatGrid items={cspItems} width={width} />}
335
361
  </>}
336
362
  {!position?.legs.length && !scenario ? <PaneStatusBody loading={!!resource.loading && !market} error={seeded.error} subject="scenario inputs">
337
363
  <Box paddingX={1}><EmptyState title="Build an options position." actions={<Button label="Add leg" onPress={addTyped} />} /></Box>
@@ -2,7 +2,7 @@ import type { AppConfig } from "../../../types/config";
2
2
  import type { Portfolio, PortfolioCash, TickerPosition, TickerRecord, Watchlist } from "../../../types/ticker";
3
3
  import { resolveCurrencyUnit } from "../../../utils/currency-units";
4
4
  import { slugifyName } from "../../../utils/slugify";
5
- import { parseCollectionRef } from "../cloud/team/collections";
5
+ import { parseCollectionRef } from "../../../team/collections";
6
6
  import { findNamedCollection } from "../../../portfolio/collection-name";
7
7
 
8
8
  export interface DeleteWatchlistResult {
@@ -2,7 +2,7 @@ import { t, tf } from "../../../../i18n";
2
2
  import { formatMarketCost, formatMarketQuantity } from "../../../../market-data/market/format";
3
3
  import type { AppConfig } from "../../../../types/config";
4
4
  import type { TickerRecord } from "../../../../types/ticker";
5
- import { parseCollectionRef } from "../../cloud/team/collections";
5
+ import { parseCollectionRef } from "../../../../team/collections";
6
6
  import { isManualPortfolio, removeTickerFromPortfolio, removeTickerFromWatchlist } from "../mutations";
7
7
 
8
8
  export interface RemovableCollection {
@@ -285,9 +285,11 @@ export function buildEventDetail({
285
285
  const comparison: EventDetailBlock[] = [
286
286
  ...(row.dateStatus && row.dateStatus !== "reported" ? [detailRow("Report date", row.date, row.dateStatus)] : []),
287
287
  detailRow("Actual", earningsInput(row.epsActual, row.epsCurrency),
288
- row.epsBasis === "unknown" ? "basis unknown" : row.epsBasis),
288
+ row.epsActual == null ? undefined : row.epsBasis === "unknown" ? "basis unknown" : row.epsBasis),
289
+ // An upcoming estimate names its basis when it is adjusted (a REIT's FFO consensus).
289
290
  detailRow("Consensus", earningsInput(row.epsEstimate, row.epsCurrency),
290
- row.epsAnalysts != null ? `${row.epsAnalysts} analysts` : undefined),
291
+ [row.epsAnalysts != null ? `${row.epsAnalysts} analysts` : null,
292
+ row.epsActual == null && row.epsBasis === "adjusted" ? "adjusted" : null].filter(Boolean).join(", ") || undefined),
291
293
  ];
292
294
  if (row.epsDifference != null) comparison.push(detailRow("Difference", earningsInput(row.epsDifference, row.epsCurrency)));
293
295
  if (row.surprisePercent != null) {
@@ -20,6 +20,7 @@ import {
20
20
  epsBasisName,
21
21
  epsSurprise,
22
22
  inFiscalMonth,
23
+ showsEpsBasis,
23
24
  type EpsBasisName,
24
25
  } from "../shared/earnings-report";
25
26
 
@@ -46,7 +47,7 @@ export interface EventRow {
46
47
  epsEstimate?: number;
47
48
  epsDifference?: number;
48
49
  surprisePercent?: number;
49
- /** A reported figure's basis; the same quarter is named the same way in ERN. */
50
+ /** A reported figure's basis, or an upcoming estimate's when it is adjusted; the same quarter is named the same way in ERN. */
50
51
  epsBasis?: EpsBasisName;
51
52
  /** Analysts behind `epsEstimate`, when known. */
52
53
  epsAnalysts?: number;
@@ -224,11 +225,12 @@ function ttmRow(
224
225
  };
225
226
  }
226
227
 
227
- /** A reported row names its basis and beat; a pending one whether its date is announced and its analysts. */
228
+ /** A reported row names its basis and beat; a pending one whether its date is announced, its analysts and an adjusted estimate's basis. */
228
229
  function earningsDetail(earning: CorporateActionsData["earnings"][number]): string {
229
230
  if (earning.epsActual == null) {
230
231
  const status = earningsDateStatus(earning.dateStatus);
231
- const parts = [status === "reported" ? null : status, earning.epsAnalysts != null ? `${earning.epsAnalysts} analysts` : null];
232
+ const parts = [status === "reported" ? null : status, earning.epsAnalysts != null ? `${earning.epsAnalysts} analysts` : null,
233
+ earningsEpsBasis(earning.epsBasis) === "adjusted" ? BASIS_TEXT.adjusted : null];
232
234
  return parts.filter(Boolean).join(", ") || "Pending";
233
235
  }
234
236
  const basis = BASIS_TEXT[epsBasisName(earningsEpsBasis(earning.epsBasis))];
@@ -341,7 +343,8 @@ export function buildEventRows(
341
343
  epsEstimate: earning.epsEstimate,
342
344
  epsDifference: earning.epsActual == null ? undefined : earning.difference,
343
345
  surprisePercent,
344
- epsBasis: earning.epsActual == null ? undefined : epsBasisName(earningsEpsBasis(earning.epsBasis)),
346
+ epsBasis: showsEpsBasis({ epsActual: earning.epsActual, epsBasis: earningsEpsBasis(earning.epsBasis) })
347
+ ? epsBasisName(earningsEpsBasis(earning.epsBasis)) : undefined,
345
348
  epsAnalysts: earning.epsAnalysts,
346
349
  dateStatus: earningsDateStatus(earning.dateStatus) ?? undefined,
347
350
  providerId: data?.providerId,
@@ -1,4 +1,5 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
+ import { proLockNote } from "../shared/pro-lock-note";
2
3
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
3
4
  import {
4
5
  attachRiskFactorsPersistence,
@@ -13,6 +14,21 @@ import { isKnownNonUsListing } from "../../../utils/sec";
13
14
  const description =
14
15
  "The company's 10-K risk factors, and what was added, dropped, or rewritten since the prior year.";
15
16
 
17
+ /**
18
+ * RISK is Pro whole (`useProFeatureWall`). The 10-Ks it reads are in the SEC
19
+ * filing list, which needs no account: `filings` lists them with a link to
20
+ * each, where Item 1A is the risk factors as written, without RISK's grouping
21
+ * or its changes since the year before.
22
+ */
23
+ function riskFactorsLockNote(lock: { gate: "sign-in" | "pro"; symbol: string | null }): string {
24
+ return proLockNote(lock, {
25
+ token: "RISK",
26
+ does: "it groups a 10-K's risk factors by theme and shows what was added, dropped or reworded since the year before",
27
+ shows: "it",
28
+ free: (ticker, company) => `gloomberb filings ${ticker} --form 10-K lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 1A holds the risk factors as written.`,
29
+ });
30
+ }
31
+
16
32
  export const riskFactorsModule: PluginModule = {
17
33
  setup(ctx) {
18
34
  attachRiskFactorsPersistence(ctx.persistence);
@@ -33,6 +49,8 @@ export const riskFactorsModule: PluginModule = {
33
49
  panes: [
34
50
  {
35
51
  id: RISK_FACTORS_PANE_ID,
52
+ reportLockNote: riskFactorsLockNote,
53
+ reportRequirements: ["Gloom Cloud Pro"],
36
54
  name: "Risk Factors",
37
55
  icon: "R",
38
56
  component: RiskFactorsPane,
@@ -283,7 +283,8 @@ function RiskFactorsReader({
283
283
  </Box>
284
284
  ) : null}
285
285
  <Box flexDirection="column">
286
- <SectionHeading marginTop={1} title="All Risk Factors" />
286
+ {/* The title names the pane; the heading only marks where the list starts after another section. */}
287
+ {report.overview || diff || report.notes.top.length > 0 ? <SectionHeading marginTop={1} title="All Risk Factors" /> : null}
287
288
  {report.risks.map((risk, index) => (
288
289
  <Prose
289
290
  key={`${index}-${risk.heading}`}
@@ -1,3 +1,4 @@
1
+ import { ApiRequestError } from "../../../api-client/errors";
1
2
  import type {
2
3
  ScannerFeedEvent,
3
4
  ScannerFlowPayload,
@@ -18,6 +19,8 @@ import {
18
19
  filterFlowEvents,
19
20
  type FlowFilters,
20
21
  } from "./flow-model";
22
+ import { explainRefusal } from "../shared/cloud-denied";
23
+ import { flowLockNote } from "./lock-note";
21
24
  import { buildHiloBarRows, filterHiloRows, formatHiloPrice, type HiloMinPrice, type HiloSort } from "./hilo-model";
22
25
 
23
26
  type ScannerName = "hilo" | "flow";
@@ -66,7 +69,10 @@ function loadScannerSnapshot<T extends ScannerPayload>(
66
69
  signal.addEventListener("abort", onAbort, { once: true });
67
70
  unsubscribe = client.subscribeScanner(scanner, (event: ScannerFeedEvent) => {
68
71
  if (event.type === "denied") {
69
- finish(() => reject(new Error(event.reason === "pro_required" ? "Pro access required" : "Sign in required")));
72
+ // The refusal a read gets from the server: 402 for a plan without access, 401 for no session.
73
+ finish(() => reject(event.reason === "pro_required"
74
+ ? new ApiRequestError("Pro access required", 402)
75
+ : new ApiRequestError("Sign in required", 401)));
70
76
  return;
71
77
  }
72
78
  const payload = event.payload as T;
@@ -277,7 +283,8 @@ function createFlowHeadless(
277
283
  ],
278
284
  describe: "Options Flow",
279
285
  async load(args, ctx) {
280
- const payload = await dependencies.load(args, ctx.apiClient, ctx.signal);
286
+ const payload = await dependencies.load(args, ctx.apiClient, ctx.signal)
287
+ .catch((error: unknown) => { throw explainRefusal(error, flowLockNote, { symbol: null }); });
281
288
  return projectFlowSnapshot(payload, {
282
289
  ...DEFAULT_FLOW_FILTERS,
283
290
  ...(args.options as Partial<FlowFilters>),
@@ -4,6 +4,7 @@ import { DEFAULT_FLOW_FILTERS, FLOW_FILTER_OPTIONS } from "./flow-model";
4
4
  import FlowPane from "./flow-pane";
5
5
  import HiloPane from "./hilo-pane";
6
6
  import { flowHeadless, hiloHeadless } from "./headless";
7
+ import { flowLockNote } from "./lock-note";
7
8
 
8
9
 
9
10
  const HILO_PANE_ID = "scanner-hilo";
@@ -80,6 +81,8 @@ export const scannerModule: PluginModule = {
80
81
  },
81
82
  {
82
83
  id: FLOW_PANE_ID,
84
+ reportLockNote: flowLockNote,
85
+ reportRequirements: ["Gloom Cloud Pro"],
83
86
  name: "Options Flow",
84
87
  icon: "F",
85
88
  component: FlowPane,
@@ -0,0 +1,17 @@
1
+ import type { PaneLock } from "../../../types/plugin";
2
+ import { proLockNote } from "../shared/pro-lock-note";
3
+
4
+ /**
5
+ * FLOW is Pro whole: the one scanner with no delayed tier to fall back to.
6
+ * The options chain needs no account and lists each contract's day volume and
7
+ * open interest, the raw counts behind a sweep or a block, without the prints.
8
+ */
9
+ export function flowLockNote(lock: PaneLock): string {
10
+ return proLockNote(lock, {
11
+ token: "FLOW",
12
+ argument: "none",
13
+ does: "it streams real-time sweeps, blocks and large premium prints across US options exchanges",
14
+ shows: "it",
15
+ free: (ticker, company) => `gloomberb options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.`,
16
+ });
17
+ }
@@ -37,6 +37,7 @@ export const SERIES_INVENTORY: readonly SeriesInventoryEntry[] = [
37
37
  { module: "realized-vol", chart: "Realized volatility cone and rolling windows", notInCatalog: "derived", reason: "The composer's realized volatility study draws it from any price series." },
38
38
  { module: "vol-surface", chart: "Implied volatility surface by strike and expiry", notInCatalog: "not-a-time-series", reason: "A surface over strike and tenor." },
39
39
  { module: "options-positioning", chart: "Open interest and gamma by strike and expiry", notInCatalog: "not-a-time-series", reason: "Plotted by strike and expiry, not by date." },
40
+ { module: "options-calculator", chart: "A linked option contract's daily premium", series: ["option:*"] },
40
41
  { module: "options-scenario", chart: "Option payoff by underlying price", notInCatalog: "not-a-time-series", reason: "A payoff curve over prices." },
41
42
  { module: "kelly-sizer", chart: "Position size against win rate", notInCatalog: "account", reason: "Drawn from the inputs typed into the sizer." },
42
43
  { module: "yield-curve", chart: "Treasury curve and each maturity's history", series: ["ust:*", "fred:T10Y2Y", "fred:T10Y3M", "fred:DFII*"] },
@@ -54,7 +55,7 @@ export const SERIES_INVENTORY: readonly SeriesInventoryEntry[] = [
54
55
  { module: "credit-documents", chart: "Principal due by year", notInCatalog: "not-a-time-series", reason: "A maturity schedule, not a history." },
55
56
  { module: "debt-maturities", chart: "A company's principal and the share due soon, across annual filings", series: ["debt-maturities:*"] },
56
57
  { module: "debt-maturities", chart: "A company's maturity wall", notInCatalog: "not-a-time-series", reason: "A schedule by maturity year from one filing; its total and near-term shares across filings are in the catalog." },
57
- { module: "doe", chart: "Weekly oil and gas inventories against their five-year range", series: ["doe:*"] },
58
+ { module: "doe", chart: "Weekly oil and gas inventories against their five-year range, and US retail gasoline and diesel prices", series: ["doe:*"] },
58
59
  { module: "cot", chart: "Net positioning by trader class, over the front-month price", series: ["cot:*"] },
59
60
  { module: "futures", chart: "Front-month futures board, charted from the board", series: ["fut:*"] },
60
61
  { module: "futures-curve", chart: "Futures curve by contract month", notInCatalog: "not-a-time-series", reason: "Plotted by delivery month; each front month is in the catalog as FUT:code." },
@@ -1,15 +1,46 @@
1
1
  import { ApiRequestError } from "../../../api-client/errors";
2
+ import type { PaneLock } from "../../../types/plugin";
3
+ import { lockNoteReason } from "./pro-lock-note";
2
4
 
3
5
  /** A Gloom Cloud refusal the reader can act on: signed out, an unverified email, or no Pro plan. */
4
- export type CloudDeniedStatus = 401 | 402 | 403;
6
+ type CloudDeniedStatus = 401 | 402 | 403;
5
7
 
6
- /** The account refusal behind a failed request, or null when it failed for another reason. */
7
- export function cloudDeniedStatus(error: unknown): CloudDeniedStatus | null {
8
- const status = error instanceof ApiRequestError ? error.status : undefined;
9
- return status === 401 || status === 402 || status === 403 ? status : null;
8
+ /**
9
+ * The account refusal behind a failed request, or null when it failed for
10
+ * another reason. Looks through the errors that wrap it, so a message written
11
+ * for the reader still says which wall it stands for.
12
+ */
13
+ function cloudDeniedStatus(error: unknown): CloudDeniedStatus | null {
14
+ for (let current = error, depth = 0; current && depth < 4; depth += 1) {
15
+ const status = current instanceof ApiRequestError ? current.status : undefined;
16
+ if (status === 401 || status === 402 || status === 403) return status;
17
+ current = current instanceof Error ? current.cause : undefined;
18
+ }
19
+ return null;
10
20
  }
11
21
 
12
- /** What the refused account needs, as a report finishes "JOBS needs ...". */
13
- export function cloudAccountNeed(status: CloudDeniedStatus): string {
14
- return status === 401 ? "sign-in" : status === 403 ? "a verified email" : "Gloom Pro";
22
+ /**
23
+ * The wall a refused read stands for, as the pane's own wall puts it up: no
24
+ * session is the sign-in wall, an unverified email the same wall asking for
25
+ * the email, a plan without access the Pro wall.
26
+ */
27
+ export function refusedLock(error: unknown): Pick<PaneLock, "gate" | "verifyEmail"> | null {
28
+ const status = cloudDeniedStatus(error);
29
+ if (status === 402) return { gate: "pro" };
30
+ return status ? { gate: "sign-in", ...status === 403 ? { verifyEmail: true } : {} } : null;
31
+ }
32
+
33
+ /**
34
+ * What a headless read throws when the server refused it behind a wall: the
35
+ * pane's lock note on one line, so every consumer of the read says what is
36
+ * locked and how to unlock it, with the refusal as its cause for `fn`, which
37
+ * prints the whole note. Any other failure is returned as it is.
38
+ */
39
+ export function explainRefusal(
40
+ error: unknown,
41
+ note: (lock: PaneLock) => string,
42
+ lock: Pick<PaneLock, "symbol" | "token">,
43
+ ): unknown {
44
+ const refused = refusedLock(error);
45
+ return refused ? new Error(lockNoteReason(note({ ...refused, ...lock })), { cause: error }) : error;
15
46
  }
@@ -2,9 +2,10 @@ import type { EarningsDateStatus, EarningsEpsBasis } from "../../../types/financ
2
2
 
3
3
  /**
4
4
  * How every earnings surface (ERN, EE, the corporate-actions events and
5
- * their headless rows) reads and names a reported quarter's EPS basis and a
6
- * report date's status. Gloom Cloud resolves one figure per quarter; this
7
- * keeps the panes from naming it differently.
5
+ * their headless rows) reads and names a reported quarter's EPS basis, an
6
+ * upcoming estimate's when Gloom Cloud names one, and a report date's
7
+ * status. Gloom Cloud resolves one figure per quarter; this keeps the panes
8
+ * from naming it differently.
8
9
  */
9
10
 
10
11
  const BASES: ReadonlySet<string> = new Set<EarningsEpsBasis>(["gaap", "adjusted"]);
@@ -27,13 +28,18 @@ export function dateStatusFromEstimateFlag(isDateEstimate: boolean | null | unde
27
28
  export type EpsBasisName = "GAAP" | "adjusted" | "unknown";
28
29
 
29
30
  /** What the basis names mean, in every report's metadata. */
30
- export const EPS_BASIS_NOTE = "One reported EPS per quarter. GAAP: the quarter's diluted statement EPS. adjusted: the consensus-basis figure the estimate is measured against, which leaves out items the statements include. unknown: no statement on a comparable share basis to tell.";
31
+ export const EPS_BASIS_NOTE = "One reported EPS per quarter. GAAP: the quarter's diluted statement EPS. adjusted: the consensus-basis figure the estimate is measured against, which leaves out items the statements include. unknown: no statement on a comparable share basis to tell. An upcoming report's estimate is labelled adjusted when it is the consensus on the adjusted basis the company's reports are on (a REIT's FFO), not a GAAP forecast.";
31
32
 
32
33
  /** The basis as rows, exports and tools name it. */
33
34
  export function epsBasisName(basis: EarningsEpsBasis | null): EpsBasisName {
34
35
  return basis === "gaap" ? "GAAP" : basis === "adjusted" ? "adjusted" : "unknown";
35
36
  }
36
37
 
38
+ /** Whether a row names a basis: a reported figure always does, an upcoming estimate only when Gloom Cloud labels it. */
39
+ export function showsEpsBasis(row: { epsActual: number | null | undefined; epsBasis: EarningsEpsBasis | null | undefined }): boolean {
40
+ return row.epsActual != null || row.epsBasis != null;
41
+ }
42
+
37
43
  /** The basis in a table cell or a detail line. */
38
44
  export function epsBasisShort(basis: EarningsEpsBasis | null): string {
39
45
  return basis === "gaap" ? "GAAP" : basis === "adjusted" ? "adj" : "?";
@@ -0,0 +1,58 @@
1
+ import type { PaneLock } from "../../../types/plugin";
2
+
3
+ /** A lock note on one line, as JSON and CSV say why a locked report is empty and as an error carries it. */
4
+ export function lockNoteReason(note: string): string {
5
+ return note.replace(/\s*\n\s*/g, " ");
6
+ }
7
+
8
+ /**
9
+ * The `reportLockNote` of a pane its wall covers whole (`useProFeatureWall`, a
10
+ * scanner's denied state): what the function is and that a wall shows none of
11
+ * it, what a free reader can still get and the command that reads it, and how
12
+ * to unlock it. One shape for every lock a reader meets in `fn`, so a refused
13
+ * read never ends in a bare "Sign in required".
14
+ */
15
+ export function proLockNote(
16
+ { gate, symbol, token: typed, verifyEmail }: PaneLock,
17
+ pane: {
18
+ /** The function's shortcut, such as `EK`; a pane that serves several names the one run in the lock. */
19
+ token: string;
20
+ /** What the function does, finishing "EK is part of Gloom Cloud Pro: it ...". */
21
+ does: string;
22
+ /** What the wall hides, as the object of "none of" and "to read": "it" or "them". */
23
+ shows: "it" | "them";
24
+ /**
25
+ * The free route and what it lists, finishing "Free, with no sign-in: ...".
26
+ * Name one only after running it signed out; leave it out when none answers.
27
+ */
28
+ free?(ticker: string, company: string): string;
29
+ /** What the wall asks for: Gloom Cloud Pro (default), or only a free Gloom account. */
30
+ needs?: "pro" | "sign-in";
31
+ /**
32
+ * Whether the function takes a ticker: `none` for a board such as FLOW,
33
+ * `optional` for one that also lists every company (CALLS, JOBS), where the
34
+ * command repeats the ticker only when the reader gave one. Default `ticker`.
35
+ */
36
+ argument?: "ticker" | "optional" | "none";
37
+ },
38
+ ): string {
39
+ const token = typed ?? pane.token;
40
+ const ticker = symbol ?? "<ticker>";
41
+ // A plan refused at the server is Pro whatever the function usually asks for.
42
+ const pro = pane.needs !== "sign-in" || gate === "pro";
43
+ const without = gate === "pro" ? "On a free account" : verifyEmail ? "Until the email is verified" : "Signed out";
44
+ const what = pro ? "is part of Gloom Cloud Pro" : "needs a free Gloom sign-in";
45
+ const account = verifyEmail
46
+ ? "verify your email (Ctrl+P, Resend Verification Email)"
47
+ : `log in or sign up${pro ? "" : " free"} from the command bar (Ctrl+P, Log In or Sign Up)`;
48
+ const unlock = gate === "pro"
49
+ ? `To read ${pane.shows} in ${token}, upgrade in the app (gloomberb, then Ctrl+P, UPGRADE)`
50
+ : `To read ${pane.shows} in ${token}, open the app (gloomberb), ${account}${pro ? ", then upgrade (Ctrl+P, UPGRADE)" : ""}`;
51
+ const bare = pane.argument === "none" || (pane.argument === "optional" && !symbol);
52
+ const command = bare ? `gloomberb fn ${token}` : `gloomberb fn ${token} ${ticker}`;
53
+ return [
54
+ `${token} ${what}: ${pane.does}. ${without} it shows none of ${pane.shows}.`,
55
+ ...pane.free ? [`Free, with no sign-in: ${pane.free(ticker, symbol ?? "the company")}`] : [],
56
+ `${unlock}; ${command} then reads with that session.`,
57
+ ].join("\n");
58
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../../components";
11
11
  import type { DataTableViewProps } from "../../../components/data-table/view";
12
12
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
13
+ import { FILL_REST } from "../../../components/ui/fill";
13
14
  import { useShortcut } from "../../../react/input";
14
15
  import { Box, ScrollBox, useUiCapabilities } from "../../../ui";
15
16
  import { isPlainKey } from "../../../utils/keyboard";
@@ -171,13 +172,17 @@ export function SeriesListDetail<Row, Column extends DataTableColumn, R extends
171
172
  // pane height leaves its lower content clipped outside the scroll viewport.
172
173
  const detailHeight = split ? bodyHeight : Math.max(1, bodyHeight - tableHeight);
173
174
  const bodyFocused = focused && !list.searchFocused;
175
+ // On the desktop and the web the query bar is taller than a row, so the
176
+ // split table and the detail take what is left under it by flex and end at
177
+ // the pane footer; the row counts above stay the terminal's.
178
+ const fill = nativePaneChrome ? FILL_REST : null;
174
179
 
175
180
  return (
176
- <Box flexDirection="column" width={width} height={height}>
181
+ <Box flexDirection="column" width={width} {...(fill ?? { height })}>
177
182
  <QueryBar width={width} search={{ ...list.search, placeholder: searchPlaceholder }} filters={filters} view={list.range} />
178
- <Box flexDirection={split ? "row" : "column"} flexGrow={1} overflow="hidden">
183
+ <Box flexDirection={split ? "row" : "column"} flexGrow={1} overflow="hidden" {...(fill ? { flexBasis: 0, minHeight: 0 } : {})}>
179
184
  <Box flexDirection="column" width={listWidth} flexShrink={0}>
180
- <Box flexDirection="column" width={listWidth} height={tableHeight} flexShrink={0} overflow="hidden">
185
+ <Box flexDirection="column" width={listWidth} overflow="hidden" {...(fill && split ? fill : { height: tableHeight, flexShrink: 0 })}>
181
186
  <DataTableView<Row, Column>
182
187
  focused={bodyFocused}
183
188
  rootWidth={listWidth}
@@ -201,8 +206,8 @@ export function SeriesListDetail<Row, Column extends DataTableColumn, R extends
201
206
  </Box>
202
207
  </Box>
203
208
 
204
- <Box flexDirection="column" flexGrow={1} width={detailWidth} height={detailHeight} overflow="hidden">
205
- <ScrollBox height={detailHeight} scrollY focusable={false}>
209
+ <Box flexDirection="column" width={detailWidth} overflow="hidden" {...(fill ? split ? { flexGrow: 1, minHeight: 0 } : fill : { flexGrow: 1, height: detailHeight })}>
210
+ <ScrollBox scrollY focusable={false} {...(fill ? fill : { height: detailHeight })}>
206
211
  {renderDetail({ width: detailContentWidth, height: detailHeight, focused: bodyFocused })}
207
212
  </ScrollBox>
208
213
  </Box>
@@ -47,7 +47,7 @@ export function validateSocialMentions(payload: SocialMentionsPayload, symbol: s
47
47
  || payload.wikipedia !== undefined && !validWikipedia(payload.wikipedia)
48
48
  || payload.reddit !== undefined && !validReddit(payload.reddit)
49
49
  || !Array.isArray(payload.pending) || !Array.isArray(payload.warnings) || payload.warnings.some((warning) => typeof warning !== "string"))
50
- throw new Error("Gloom Cloud returned invalid social mention history");
50
+ throw new Error("Social mention history is invalid");
51
51
  return payload;
52
52
  }
53
53
 
@@ -55,7 +55,7 @@ export async function fetchSocialMentions(symbol: string, range: SocialMentionsR
55
55
  client: Pick<typeof apiClient, "getCloudSocialMentions"> = apiClient): Promise<SocialMentionsPayload> {
56
56
  try { return validateSocialMentions(await client.getCloudSocialMentions(symbol, range), symbol, range); }
57
57
  catch (error) {
58
- if (error instanceof ApiRequestError && error.status === 404) throw new Error("Social mentions are not available on this Gloom Cloud server yet");
58
+ if (error instanceof ApiRequestError && error.status === 404) throw new Error("Social mentions are not available yet");
59
59
  throw error;
60
60
  }
61
61
  }
@@ -77,7 +77,7 @@ export async function loadSocialMentionPosts(symbol: string, date: string): Prom
77
77
  const result = await socialMentionPostsCache.load(`${symbol}:${date}`, async () => {
78
78
  const data = await apiClient.getCloudSocialMentionPosts(symbol, date);
79
79
  if (data?.symbol !== symbol || data.day !== date || !Array.isArray(data.posts) || !data.posts.every(validPost))
80
- throw new Error("Gloom Cloud returned invalid social posts");
80
+ throw new Error("Social posts are invalid");
81
81
  return data;
82
82
  });
83
83
  if (result.error && !result.data) throw result.error;
@@ -1,12 +1,14 @@
1
1
  import type { SocialMentionsRange } from "../../../api-client/social-mentions";
2
2
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
3
+ import { explainRefusal } from "../shared/cloud-denied";
3
4
  import { fetchSocialMentions } from "./client";
5
+ import { socialMentionsLockNote } from "./lock-note";
4
6
  import { socialCount, socialDayRows, socialRatio, socialStance, topPostCell } from "./model";
5
7
 
6
8
  export const socialMentionsHeadless: HeadlessPaneDefinition<"bundle"> = {
7
9
  shape: "bundle", argument: { kind: "ticker", description: "Ticker with a searchable cashtag (up to six letters and digits).", placeholder: "ticker" },
8
10
  freshness: { source: "X posts", status: "not-a-feed", basis: "daily post counts" },
9
- discovery: { aliases: ["BUZZ"], dataRequirements: ["Gloom Cloud X mention history"],
11
+ discovery: { aliases: ["BUZZ"], dataRequirements: ["Free Gloom sign-in", "Gloom Cloud X mention history"],
10
12
  limitations: ["Daily $cashtag posts on X in UTC days, spam included.", "Top posts and stance cover days that were looked up.", "Views exist from 2022-12-22."] },
11
13
  options: [
12
14
  { key: "range", type: "enum", description: "History window.", values: [{ value: "1y" }, { value: "5y" }, { value: "max" }], defaultValue: "1y", settingKey: "socialRange" },
@@ -14,7 +16,9 @@ export const socialMentionsHeadless: HeadlessPaneDefinition<"bundle"> = {
14
16
  describe: (args) => `Social mentions | ${args.symbols[0]}`,
15
17
  async load(args, ctx) {
16
18
  const range = (["1y", "5y", "max"].includes(String(args.options.range)) ? args.options.range : "1y") as SocialMentionsRange;
17
- const data = await fetchSocialMentions(String(args.symbols[0]).toUpperCase(), range, ctx.apiClient);
19
+ const symbol = String(args.symbols[0]).toUpperCase();
20
+ const data = await fetchSocialMentions(symbol, range, ctx.apiClient)
21
+ .catch((error: unknown) => { throw explainRefusal(error, socialMentionsLockNote, { symbol }); });
18
22
  return { complete: !data.pending.length, errors: data.warnings,
19
23
  sections: [{ title: "Daily posts on X", columns: [
20
24
  { key: "day", header: "Date" },
@@ -3,6 +3,7 @@ import { SOCIAL_MENTION_SERIES } from "./series";
3
3
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
4
4
  import { socialMentionPostsCache, socialMentionsCache } from "./client";
5
5
  import { socialMentionsHeadless } from "./headless";
6
+ import { socialMentionsLockNote } from "./lock-note";
6
7
  import { SocialMentionsPane } from "./pane";
7
8
 
8
9
  const socialMentionsSettings = [
@@ -11,7 +12,7 @@ const socialMentionsSettings = [
11
12
  ];
12
13
  export const socialMentionsModule: PluginModule = {
13
14
  series: SOCIAL_MENTION_SERIES,
14
- panes: [{ id: "social-mentions", name: "Social Mentions", icon: "B", component: SocialMentionsPane,
15
+ panes: [{ id: "social-mentions", reportLockNote: socialMentionsLockNote, name: "Social Mentions", icon: "B", component: SocialMentionsPane,
15
16
  defaultPosition: "right", tickerFollower: true, defaultMode: "floating", defaultFloatingSize: { width: 96, height: 30 },
16
17
  tableExport: true, headless: socialMentionsHeadless, settings: { title: "Social Mentions", fields: socialMentionsSettings } }],
17
18
  paneTemplates: [{ ...createTickerSurfacePaneTemplate({ id: "social-mentions-pane", paneId: "social-mentions", label: "Social Mentions",
@@ -0,0 +1,15 @@
1
+ import type { PaneLock } from "../../../types/plugin";
2
+ import { proLockNote } from "../shared/pro-lock-note";
3
+
4
+ /**
5
+ * BUZZ needs a signed-in account, not a plan. No command reads the posts it
6
+ * counts without one, so the note names no free route.
7
+ */
8
+ export function socialMentionsLockNote(lock: PaneLock): string {
9
+ return proLockNote(lock, {
10
+ token: "BUZZ",
11
+ needs: "sign-in",
12
+ does: "it counts the daily posts on X that name a company's cashtag, with the top posts and their stance",
13
+ shows: "it",
14
+ });
15
+ }