gloomberb 0.18.2 → 0.18.3

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 (157) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +106 -5
  3. package/src/api-client/data.ts +15 -0
  4. package/src/api-client/index.ts +2 -0
  5. package/src/api-client/request.ts +5 -3
  6. package/src/api-client/telemetry.ts +17 -0
  7. package/src/app/global-shortcuts.ts +1 -16
  8. package/src/app/runtime/update.ts +28 -8
  9. package/src/architecture/pane-tabs-allowlist.json +65 -0
  10. package/src/cli/commands/market.ts +73 -33
  11. package/src/cli/commands/ticker.ts +82 -25
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +8 -0
  14. package/src/cli/issuer-membership.ts +103 -0
  15. package/src/cli/listing-arg.ts +15 -2
  16. package/src/cli/name-entries.ts +51 -12
  17. package/src/cli/pane-functions/capabilities.ts +2 -0
  18. package/src/cli/pane-functions/catalog.ts +16 -0
  19. package/src/cli/pane-functions/dom.ts +2 -1
  20. package/src/cli/pane-functions/headless.ts +25 -6
  21. package/src/cli/pane-functions/index.ts +64 -5
  22. package/src/cli/pane-functions/resolver.ts +19 -5
  23. package/src/cli/report-tables.ts +136 -18
  24. package/src/cli/result.ts +12 -4
  25. package/src/cli/symbol-list.ts +47 -11
  26. package/src/components/chart/composite/composite-chart.tsx +14 -5
  27. package/src/components/chart/composite/drawing-store.ts +26 -6
  28. package/src/components/chart/composite/interactions.ts +2 -1
  29. package/src/components/chart/composite/panel-surface.tsx +11 -0
  30. package/src/components/chart/composite/types.ts +6 -0
  31. package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
  32. package/src/components/command-bar/routes/root/command-items.ts +32 -6
  33. package/src/components/data-table/view.tsx +23 -3
  34. package/src/components/index.ts +1 -0
  35. package/src/components/layout/detached-pane-shell.tsx +1 -0
  36. package/src/components/layout/floating-pane.tsx +1 -0
  37. package/src/components/layout/header.tsx +228 -55
  38. package/src/components/layout/pane/header.tsx +10 -3
  39. package/src/components/layout/pane/index.tsx +1 -0
  40. package/src/components/layout/pane/pane-tabs.tsx +9 -1
  41. package/src/components/ui/stat-grid.tsx +28 -3
  42. package/src/components/ui/status.tsx +3 -1
  43. package/src/core/app-services.ts +4 -0
  44. package/src/i18n/es.ts +22 -0
  45. package/src/i18n/ja.ts +22 -0
  46. package/src/i18n/ko.ts +22 -0
  47. package/src/i18n/zh-cn.ts +22 -0
  48. package/src/i18n/zh-tw.ts +22 -0
  49. package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
  50. package/src/plugins/builtin/alerts/events.ts +1 -0
  51. package/src/plugins/builtin/alerts/research-rules.ts +1 -0
  52. package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
  53. package/src/plugins/builtin/attention/pane.tsx +16 -14
  54. package/src/plugins/builtin/awards/pane.tsx +6 -6
  55. package/src/plugins/builtin/backtest/export.ts +1 -1
  56. package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
  57. package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
  58. package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
  59. package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
  60. package/src/plugins/builtin/catalysts/client.ts +2 -2
  61. package/src/plugins/builtin/catalysts/pane.tsx +3 -3
  62. package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
  63. package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
  64. package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
  65. package/src/plugins/builtin/company-attention/pane.tsx +13 -11
  66. package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
  67. package/src/plugins/builtin/correlation/headless.ts +2 -1
  68. package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
  69. package/src/plugins/builtin/correlation/settings.ts +1 -0
  70. package/src/plugins/builtin/cot/pane.tsx +4 -3
  71. package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
  72. package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
  73. package/src/plugins/builtin/doe/pane.tsx +1 -1
  74. package/src/plugins/builtin/equity-screener/headless.ts +10 -0
  75. package/src/plugins/builtin/equity-screener/model.ts +28 -0
  76. package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
  77. package/src/plugins/builtin/exposure/pane.tsx +10 -9
  78. package/src/plugins/builtin/gpu/board.tsx +4 -1
  79. package/src/plugins/builtin/gpu/changes.tsx +10 -10
  80. package/src/plugins/builtin/gpu/equities.tsx +4 -1
  81. package/src/plugins/builtin/gpu/history.tsx +4 -2
  82. package/src/plugins/builtin/gpu/pane.tsx +5 -3
  83. package/src/plugins/builtin/holders/client.ts +2 -1
  84. package/src/plugins/builtin/holders/headless.ts +2 -1
  85. package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
  86. package/src/plugins/builtin/insider/base-values.ts +11 -7
  87. package/src/plugins/builtin/insider/client.ts +3 -0
  88. package/src/plugins/builtin/insider/headless.ts +19 -6
  89. package/src/plugins/builtin/insider/index.tsx +17 -8
  90. package/src/plugins/builtin/insider/issuer.ts +34 -4
  91. package/src/plugins/builtin/insider/signals-client.ts +2 -2
  92. package/src/plugins/builtin/insider/table-model.ts +35 -13
  93. package/src/plugins/builtin/insider/table.tsx +5 -2
  94. package/src/plugins/builtin/iv-history/events.ts +145 -0
  95. package/src/plugins/builtin/iv-history/format.ts +3 -0
  96. package/src/plugins/builtin/iv-history/headless.ts +91 -7
  97. package/src/plugins/builtin/jodi/pane.tsx +4 -4
  98. package/src/plugins/builtin/market-movers/client.ts +4 -3
  99. package/src/plugins/builtin/market-movers/screener.ts +4 -3
  100. package/src/plugins/builtin/notes/cli.ts +1 -1
  101. package/src/plugins/builtin/notes/export.ts +17 -6
  102. package/src/plugins/builtin/notes/files.ts +16 -5
  103. package/src/plugins/builtin/options/enrichment-model.ts +10 -9
  104. package/src/plugins/builtin/options/model-spot.ts +70 -4
  105. package/src/plugins/builtin/options/view.tsx +41 -13
  106. package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
  107. package/src/plugins/builtin/options-positioning/headless.ts +11 -3
  108. package/src/plugins/builtin/options-positioning/model.ts +24 -0
  109. package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
  110. package/src/plugins/builtin/options-scenario/client.ts +19 -1
  111. package/src/plugins/builtin/options-scenario/headless.ts +13 -6
  112. package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
  113. package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
  114. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  115. package/src/plugins/builtin/power/client.ts +2 -2
  116. package/src/plugins/builtin/power/pane.tsx +36 -31
  117. package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
  118. package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
  119. package/src/plugins/builtin/seasonality/client.ts +2 -1
  120. package/src/plugins/builtin/sectors/client.ts +4 -3
  121. package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
  122. package/src/plugins/builtin/shared/research-trading.tsx +2 -0
  123. package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
  124. package/src/plugins/builtin/shared/volatility/options.ts +2 -1
  125. package/src/plugins/builtin/social-mentions/client.ts +2 -1
  126. package/src/plugins/builtin/vol-surface/client.ts +23 -3
  127. package/src/plugins/builtin/vol-surface/headless.ts +33 -14
  128. package/src/plugins/builtin/vol-surface/model.ts +30 -10
  129. package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
  130. package/src/plugins/builtin/vol-surface/stored.ts +34 -3
  131. package/src/plugins/builtin/vol-surface/tables.ts +14 -0
  132. package/src/renderers/dom/desktop/controls.tsx +1 -1
  133. package/src/renderers/dom/desktop/query-bar.tsx +2 -0
  134. package/src/renderers/dom/host/tabs.tsx +16 -5
  135. package/src/renderers/dom/host/top-surface.ts +127 -0
  136. package/src/renderers/dom/styles.css +39 -0
  137. package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
  138. package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
  139. package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
  140. package/src/renderers/electrobun/view/news-source.ts +4 -2
  141. package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
  142. package/src/renderers/opentui/start.tsx +7 -0
  143. package/src/sources/gloomberb-cloud/index.ts +7 -0
  144. package/src/sources/provider-router/index.ts +16 -0
  145. package/src/telemetry/crash-reports.ts +4 -0
  146. package/src/telemetry/update-failures-node.ts +59 -0
  147. package/src/telemetry/update-failures.ts +307 -0
  148. package/src/tickers/company-name-listing.ts +9 -7
  149. package/src/tickers/company-name.ts +43 -0
  150. package/src/tickers/listing-fate.ts +30 -0
  151. package/src/types/data-provider.ts +6 -0
  152. package/src/types/headless.ts +7 -0
  153. package/src/types/issuer-listings.ts +28 -0
  154. package/src/updater/failure.ts +163 -0
  155. package/src/updater/index.ts +87 -23
  156. package/src/utils/currency-pair.ts +26 -0
  157. package/src/utils/sec.ts +68 -0
@@ -1,3 +1,4 @@
1
+ import { unavailableText } from "../../../components/ui/status";
1
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
3
  import { delayedFooterSegment, FieldGrid, KeyValueRow, QueryBar, usePaneFooter, usePaneNoticeFooter, type GridField } from "../../../components";
3
4
  import { CompositeChart } from "../../../components/chart/composite/composite-chart";
@@ -194,7 +195,7 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
194
195
  const calculation = useMemo(() => {
195
196
  const unavailable = seedError ?? (american ? dividendInput.error : null)
196
197
  ?? (surfaceSource && !draft.symbol ? "Choose an underlying ticker for surface IV." : null)
197
- ?? (surfaceSource && !surfaceResource.loading && surface?.volatility == null ? surface?.error ?? surfaceResource.error ?? "Surface IV unavailable." : null);
198
+ ?? (surfaceSource && !surfaceResource.loading && surface?.volatility == null ? surface?.error ?? surfaceResource.error ?? unavailableText("Surface IV") : null);
198
199
  if (unavailable || surfaceSource && surface?.volatility == null) {
199
200
  return { valuation: null, implied: { volatility: null, note: null }, problem: unavailable };
200
201
  }
@@ -400,7 +401,7 @@ function ContractPremiumChart({
400
401
  width: number;
401
402
  focused: boolean;
402
403
  }) {
403
- const empty = unavailable ? "Premium history unavailable." : series.length < 1 ? "No premium history." : undefined;
404
+ const empty = unavailable ? unavailableText("Premium history") : series.length < 1 ? "No premium history." : undefined;
404
405
  return (
405
406
  <Box flexDirection="column" paddingX={1} height={PREMIUM_CHART_ROWS} flexShrink={0}>
406
407
  {empty && !loading ? (
@@ -2,6 +2,8 @@ import type { HeadlessPaneDefinition, HeadlessPaneEntry } from "../../../types/p
2
2
  import { expirationOptionSeconds, expiryIsoDate, OPTION_EXPIRATION_FORMAT, OPTION_EXPIRATION_PLACEHOLDER } from "../../../utils/option-expiry";
3
3
  import { formatUtcTime } from "../../../utils/utc-time";
4
4
  import { loadGamma, loadOpenInterest, positioningSymbol, type GammaPayload, type OpenInterestPayload } from "./client";
5
+ import { listingFateReport, loadListingFate } from "../../../tickers/listing-fate";
6
+ import { resolveHeadlessInstrument } from "../shared/headless-market-data";
5
7
  import {
6
8
  ALL_EXPIRIES,
7
9
  expiryLabel,
@@ -10,6 +12,7 @@ import {
10
12
  formatDistance,
11
13
  formatGamma,
12
14
  formatLevel,
15
+ formatPositioningSpot,
13
16
  formatPayout,
14
17
  formatRatio,
15
18
  formatStrike,
@@ -49,7 +52,7 @@ function summary(openInterest: OpenInterestPayload, dealer: GammaPayload | null)
49
52
  { key: "oiDate", label: "Open interest as of", value: openInterest.oiDate },
50
53
  ];
51
54
  const textEntries: HeadlessPaneEntry[] = [
52
- { label: "Spot", value: spot, formatted: spot == null ? "--" : spotAsOf ? `${formatLevel(spot)} at ${time(spotAsOf)}` : formatLevel(spot) },
55
+ { label: "Spot", value: spot, formatted: formatPositioningSpot(spot, spotAsOf) },
53
56
  ...(dealer ? [{ label: "Quotes as of", value: dealer.quotesAsOf, formatted: time(dealer.quotesAsOf) }] : []),
54
57
  { label: "Open interest as of", value: openInterest.oiDate, formatted: openInterest.oiDate ?? "--" },
55
58
  ];
@@ -118,7 +121,7 @@ export const optionsPositioningHeadless = (initialTab: PositioningTab): Headless
118
121
  // VIX has no dealer gamma, so its expiries have no column for it.
119
122
  const expiryGamma = !(allExpiries instanceof Error) && allExpiries.total ? netGammaByExpiry(allExpiries) : null;
120
123
  const gammaColumn = allExpiries instanceof Error || !!allExpiries.total;
121
- const errors = [
124
+ const loadErrors = [
122
125
  ...openInterest.warnings,
123
126
  ...(dealer instanceof Error ? [`Dealer gamma: ${dealer.message}`] : [
124
127
  ...dealer.warnings,
@@ -126,6 +129,11 @@ export const optionsPositioningHeadless = (initialTab: PositioningTab): Headless
126
129
  ]),
127
130
  ...(everyExpiry instanceof Error ? [`Net GEX by expiry: ${everyExpiry.message}`] : []),
128
131
  ];
132
+ // A symbol with no listed open interest says what became of it when something did (TWTR stopped
133
+ // trading, FB is a fund now) in place of the load's errors.
134
+ const fate = openInterest.expiries.length || expiry ? null
135
+ : listingFateReport(await loadListingFate(await resolveHeadlessInstrument(ctx, symbol), ctx.marketData), { served: false }, loadErrors);
136
+ const errors = fate?.errors ?? loadErrors;
129
137
  const changes = openInterest.previousOiDate != null;
130
138
  const gammaShown = dealer instanceof Error || !dealer.total ? null : dealer;
131
139
  const scope = dealer instanceof Error ? null : dealer.expiry ? expiryLabel(dealer.expiry) : "all expiries";
@@ -173,7 +181,7 @@ export const optionsPositioningHeadless = (initialTab: PositioningTab): Headless
173
181
  ],
174
182
  complete: !!openInterest.expiries.length && !(dealer instanceof Error) && errors.length === 0,
175
183
  unavailableSymbols: openInterest.expiries.length ? [] : [symbol],
176
- errors,
184
+ errors, ...(fate?.notes.length ? { notes: fate.notes } : {}),
177
185
  metadata: {
178
186
  underlying: openInterest.underlying,
179
187
  oiDate: openInterest.oiDate,
@@ -1,5 +1,6 @@
1
1
  import { formatCompact, formatCompactAmount } from "../../../utils/format";
2
2
  import { formatExpDate } from "../../../utils/options";
3
+ import { modelSpotLabel, modelSpotStamp, modelSpotStampVariants, observedModelSpot } from "../options/model-spot";
3
4
 
4
5
  export type PositioningTab = "strikes" | "expiries" | "gex";
5
6
  export const POSITIONING_TABS: { value: PositioningTab; label: string }[] = [
@@ -66,6 +67,29 @@ export function formatRatio(value: number | null | undefined): string {
66
67
  export function formatLevel(value: number | null | undefined): string {
67
68
  return value == null || !Number.isFinite(value) ? "--" : value.toFixed(2);
68
69
  }
70
+ /**
71
+ * The spot Gloom Cloud read the distances and dealer gamma at, stamped as
72
+ * every options pane stamps its spot (`modelSpotStamp`), as a stat cell
73
+ * carries it: `pre-market Wed 21 Oct 09:35 UTC`, its shorter forms, then
74
+ * none, so the band keeps the columns it has in regular hours. Nothing for
75
+ * the regular session's price.
76
+ */
77
+ export function positioningSpotDetail(spot: number | null | undefined, spotAsOf: string | null | undefined):
78
+ { detail?: string; detailVariants?: string[] } {
79
+ const at = spotAsOf ? Date.parse(spotAsOf) : Number.NaN;
80
+ if (spot == null || !Number.isFinite(at)) return {};
81
+ const observed = observedModelSpot(spot, at);
82
+ const stamp = modelSpotStamp(observed);
83
+ return stamp ? { detail: stamp, detailVariants: [...modelSpotStampVariants(observed), ""] } : {};
84
+ }
85
+
86
+ /** The spot as a report says it: `770.88 pre-market Wed 21 Oct 09:35 UTC`, `770.10 last Wed 21 Oct 14:05 UTC`. */
87
+ export function formatPositioningSpot(spot: number | null | undefined, spotAsOf: string | null | undefined): string {
88
+ const at = spotAsOf ? Date.parse(spotAsOf) : Number.NaN;
89
+ if (spot == null || !Number.isFinite(spot)) return "--";
90
+ return Number.isFinite(at) ? `${formatLevel(spot)} ${modelSpotLabel(observedModelSpot(spot, at))}` : formatLevel(spot);
91
+ }
92
+
69
93
  /** Where a level sits against spot: +0.6%. */
70
94
  export function formatDistance(level: number | null | undefined, spot: number | null | undefined): string {
71
95
  if (level == null || spot == null || !(spot > 0)) return "--";
@@ -57,6 +57,7 @@ import {
57
57
  formatDistance,
58
58
  formatGamma,
59
59
  formatLevel,
60
+ positioningSpotDetail,
60
61
  formatPayout,
61
62
  formatRatio,
62
63
  formatStrike,
@@ -204,6 +205,7 @@ function OptionsPositioningView({ width, height, focused, symbol }: PaneProps &
204
205
  const openInterest = useAsyncResource<OpenInterestPayload>(openInterestLoader, { keepPreviousData: true });
205
206
  const data = openInterest.data;
206
207
  const spot = data?.spot ?? null;
208
+ const spotStamp = useMemo(() => positioningSpotDetail(spot, data?.spotAsOf), [spot, data?.spotAsOf]);
207
209
  const expiries = data?.expiries ?? [];
208
210
  const shownExpiry = data?.expiry ?? requestedExpiry;
209
211
  const changes = data?.previousOiDate != null;
@@ -264,9 +266,7 @@ function OptionsPositioningView({ width, height, focused, symbol }: PaneProps &
264
266
  enabled: expiryIndex < expiryDates.length - 1, onSelect: () => stepExpiry(1) },
265
267
  ], [expiryDates.length, expiryIndex, stepExpiry, tab]);
266
268
 
267
- const { strip: tabStrip, rows: tabRows } = usePaneTabs(data?.expiries.length
268
- ? { tabs, activeValue: tab, onSelect: (value: string) => setTab(value), focused, dense: true }
269
- : null);
269
+ const { strip: tabStrip, rows: tabRows } = usePaneTabs({ tabs, activeValue: tab, onSelect: (value: string) => setTab(value), focused, dense: true });
270
270
  const bodyHeight = Math.max(3, height - tabRows);
271
271
 
272
272
  // Footer: what the data is (delayed or real time, the OI session), and its gaps.
@@ -335,13 +335,13 @@ function OptionsPositioningView({ width, height, focused, symbol }: PaneProps &
335
335
  const strikeFigures = useMemo<StatItem[]>(() => !callTotal ? [] : [
336
336
  { id: "max-pain", label: "Max pain", value: maxPain == null ? "--" : formatStrike(maxPain), color: colors.warning,
337
337
  detail: maxPain == null ? undefined : `${formatDistance(maxPain, spot)} vs spot` },
338
- { id: "spot", label: "Spot", value: formatLevel(spot) },
338
+ { id: "spot", label: "Spot", value: formatLevel(spot), ...spotStamp },
339
339
  { id: "pc", label: "P/C OI", value: formatRatio(callTotal.putCallRatio) },
340
340
  { id: "calls", label: "Call OI", value: formatCompact(callTotal.callOI), color: colors.positive,
341
341
  detail: callTotal.callChange == null ? undefined : formatCountChange(callTotal.callChange) },
342
342
  { id: "puts", label: "Put OI", value: formatCompact(callTotal.putOI), color: colors.negative,
343
343
  detail: callTotal.putChange == null ? undefined : formatCountChange(callTotal.putChange) },
344
- ], [callTotal, colors.negative, colors.positive, colors.warning, maxPain, spot]);
344
+ ], [callTotal, colors.negative, colors.positive, colors.warning, maxPain, spot, spotStamp]);
345
345
  const selectedStrikeRow = strikes.find((row) => strikeKey(row) === strikeSelectedId) ?? null;
346
346
  const strikeStrip = useMemo<ChartStripSpec | null>(() => strikeWindow.length < 3 ? null : {
347
347
  label: "Open interest by strike",
@@ -404,9 +404,9 @@ function OptionsPositioningView({ width, height, focused, symbol }: PaneProps &
404
404
  split: [{ id: "calls", value: calls, color: colors.positive }, { id: "puts", value: puts, color: colors.negative }] },
405
405
  { id: "pc", label: "P/C OI", value: formatRatio(calls > 0 ? puts / calls : null) },
406
406
  { id: "largest", label: "Largest expiry", value: expiryLabel(largest.date), detail: formatCompact(expirySize(largest)) },
407
- { id: "spot", label: "Spot", value: formatLevel(spot) },
407
+ { id: "spot", label: "Spot", value: formatLevel(spot), ...spotStamp },
408
408
  ];
409
- }, [changes, colors.negative, colors.positive, expiries, spot]);
409
+ }, [changes, colors.negative, colors.positive, expiries, spot, spotStamp]);
410
410
  const selectedExpiryRow = expiries.find((row) => row.date === expirySelectedId) ?? null;
411
411
  const expiryStrip = useMemo<ChartStripSpec | null>(() => expiries.length < 3 ? null : {
412
412
  label: "Open interest by expiry",
@@ -479,7 +479,7 @@ function OptionsPositioningView({ width, height, focused, symbol }: PaneProps &
479
479
  })),
480
480
  ...(band ? [{ id: "band", label: "Dealer range", value: `${formatGamma(band.low)} to ${formatGamma(band.high)}`,
481
481
  detail: `${shares} with dealers${gammaData.boxes?.length ? ", boxes out" : ""}` }] : []),
482
- { id: "spot", label: "Spot", value: formatLevel(gammaSpot) },
482
+ { id: "spot", label: "Spot", value: formatLevel(gammaSpot), ...positioningSpotDetail(gammaSpot, gammaData.spotAsOf) },
483
483
  ], [band, colors.negative, colors.positive, gammaData, gammaSpot, shares, walls]);
484
484
  // The assumption rides in the legend, where the numbers it changes are read; the
485
485
  // dealer range figure names its own share. It gives way before the series name does.
@@ -610,13 +610,11 @@ function OptionsPositioningView({ width, height, focused, symbol }: PaneProps &
610
610
  }
611
611
 
612
612
  return <Box width={width} height={height} flexDirection="column">
613
+ {tabStrip}
613
614
  <PaneStatusBody loading={openInterest.loading && !data} error={!data ? openInterest.error : null}
614
615
  empty={!!data && !data.expiries.length} subject="open interest"
615
616
  emptyTitle={`No listed options for ${parsePublicTickerKey(symbol).symbol.replace(/^\^/, "")}.`}>
616
- {data?.expiries.length ? <>
617
- {tabStrip}
618
- {content}
619
- </> : null}
617
+ {data?.expiries.length ? content : null}
620
618
  </PaneStatusBody>
621
619
  </Box>;
622
620
  }
@@ -13,7 +13,7 @@ import type { YieldPoint } from "../yield-curve/treasury-data";
13
13
  import { parseLegs, scenarioCurrency, validatePosition, type ScenarioControls, type ScenarioLeg, type ScenarioPosition } from "./model";
14
14
  import { loadModelDividendYield, type ModelDividendYield } from "../../../market-data/model-dividend-yield";
15
15
  import { abortable, abortError } from "../../../utils/async-deadline";
16
- import { extendedSession } from "../options/model-spot";
16
+ import { extendedSession, modelSpotStamp, modelSpotStampVariants, type OptionModelSpot } from "../options/model-spot";
17
17
  import { errorMessage } from "../../../utils/errors";
18
18
 
19
19
  export interface ScenarioMarketSnapshot {
@@ -191,6 +191,24 @@ export function scenarioSpotBasis(
191
191
  return (market?.underlyingQuote ? extendedSession(market.underlyingQuote) : null) ?? "last price";
192
192
  }
193
193
 
194
+ const EXTENDED_BASES: readonly string[] = ["pre-market", "after hours", "overnight"];
195
+
196
+ /**
197
+ * A spot basis as the pane and report name it: an extended-session print with
198
+ * its time, stamped as every options pane stamps its spot (`modelSpotStamp`),
199
+ * `pre-market Wed 21 Oct 09:42 UTC`; any other basis as it is.
200
+ */
201
+ export function scenarioSpotStamp(basis: string, observedAt: number): string {
202
+ if (!EXTENDED_BASES.includes(basis)) return basis;
203
+ return modelSpotStamp({ basis: "last", session: basis as OptionModelSpot["session"], observedAt }) ?? basis;
204
+ }
205
+
206
+ /** That stamp's shorter forms, longest first (`modelSpotStampVariants`): `pre-market 09:42 UTC`, `pre-market`. */
207
+ export function scenarioSpotStampVariants(basis: string, observedAt: number): string[] {
208
+ if (!EXTENDED_BASES.includes(basis)) return [];
209
+ return modelSpotStampVariants({ basis: "last", session: basis as OptionModelSpot["session"], observedAt });
210
+ }
211
+
194
212
  function supplied(value: unknown): boolean { return value != null && value !== ""; }
195
213
 
196
214
  /** A position typed whole: its legs and the spot, rate and dividend yield they are valued at, so no market data is read. */
@@ -6,7 +6,7 @@ import {
6
6
  import { resolveHeadlessInstrument } from "../shared/headless-market-data";
7
7
  import {
8
8
  createScenarioDependencies, loadScenarioMarket, SCENARIO_STRATEGIES, scenarioControlsFromSettings, scenarioPositionFromSettings,
9
- scenarioSpotBasis, suppliedScenarioInputs, type ScenarioMarketSnapshot,
9
+ scenarioSpotBasis, scenarioSpotStamp, suppliedScenarioInputs, type ScenarioMarketSnapshot,
10
10
  } from "./client";
11
11
  import { scenarioFreshness } from "./freshness";
12
12
  import { cashSecuredPut, cspReportEntries, loadNextEarningsDate } from "./csp";
@@ -15,6 +15,7 @@ import { buildScenario, currencyLabel, parseLegs, scenarioValueUnit } from "./mo
15
15
  import { hedgeInputsFromSettings, hedgeReportEntries, scenarioHedgeBudget } from "./hedge";
16
16
  import { collarReportEntries, scenarioCollar } from "./collar";
17
17
  import { modelDividendYieldNote } from "../../../market-data/model-dividend-yield";
18
+ import { listingFateEmpty, loadListingFate } from "../../../tickers/listing-fate";
18
19
 
19
20
  // Float noise at a flat origin or a breakeven prints as 0.00, not -0.00.
20
21
  const money = (value: unknown) => typeof value === "number" ? (Math.abs(value) < 0.005 ? 0 : value).toFixed(2) : "--";
@@ -82,12 +83,13 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
82
83
  hedgeInputsFromSettings(settings);
83
84
  const suppliedInputs = suppliedScenarioInputs(settings);
84
85
  let market: ScenarioMarketSnapshot;
86
+ let instrument: Awaited<ReturnType<typeof resolveHeadlessInstrument>> | null = null;
85
87
  if (suppliedInputs) {
86
88
  // An explicit position can be valued offline and must never depend on unrelated market requests.
87
89
  market = { ...parsePublicTickerKey(symbol), spot: null, currency: "", asOf: Date.now(), chain: null, expirationDates: [], rate: null,
88
90
  dividendYield: null, source: null, underlyingQuote: null, rateAsOf: [], warnings: [] };
89
91
  } else {
90
- const instrument = await resolveHeadlessInstrument(ctx, symbol);
92
+ instrument = await resolveHeadlessInstrument(ctx, symbol);
91
93
  const typedExpiries = typeof settings.legs === "string" && settings.legs.trim() ? parseLegs(settings.legs).map((leg) => leg.expiration) : [];
92
94
  const expiration = readOptionExpiration(args.options.expiration);
93
95
  market = await loadScenarioMarket({ instrument, expiration: expiration ?? undefined,
@@ -104,8 +106,13 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
104
106
  // A typed rate or yield stands in for the source that could not supply one.
105
107
  const marketWarnings = market.warnings.filter((warning) => !(supplied(settings.dividendYield) && warning.startsWith("Dividend yield unavailable"))
106
108
  && !(supplied(settings.rate) && warning.startsWith("Treasury")));
107
- const errors = scenario ? [...new Set([...marketWarnings, ...scenario.warnings])]
109
+ const loadErrors = scenario ? [...new Set([...marketWarnings, ...scenario.warnings])]
108
110
  : [...marketWarnings, "No position supplied; add --legs or select --strategy"];
111
+ // A symbol with no option chain at all says what became of it when something did (TWTR stopped
112
+ // trading, FB is a fund now) in place of the chain's errors.
113
+ const fate = instrument && !market.chain && !scenario
114
+ ? listingFateEmpty(await loadListingFate(instrument, ctx.marketData, market.underlyingQuote ? { quote: market.underlyingQuote } : {}), loadErrors) : null;
115
+ const errors = fate?.errors ?? loadErrors;
109
116
  const midVolatility = !!position?.legs.some((leg) => leg.volatilitySource === "mid");
110
117
  // A seeded strategy starts at the spot its quote mids imply; the last print is reported beside it.
111
118
  const lastPrint = midVolatility && !supplied(settings.spot) && market.spot != null && money(market.spot) !== money(position!.spot)
@@ -135,9 +142,9 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
135
142
  ...(midVolatility ? { volatilityFrom: leg.volatilitySource === "mid" ? "quote mid" : "provider" } : {}) })) },
136
143
  { title: "Inputs", entries: [
137
144
  { key: "spot", label: lastPrint != null ? "Spot implied by option quotes" : "Spot", value: position!.spot,
138
- formatted: `${money(position!.spot)} ${PRICE_UNIT}${lastPrint == null && spotBasis !== "last price" ? ` (${spotBasis})` : ""}` },
145
+ formatted: `${money(position!.spot)} ${PRICE_UNIT}${lastPrint == null && spotBasis !== "last price" ? ` (${scenarioSpotStamp(spotBasis!, position!.asOf)})` : ""}` },
139
146
  ...(lastPrint != null ? [{ key: "last", label: "Last price", value: lastPrint,
140
- formatted: `${money(lastPrint)} ${PRICE_UNIT}${lastSession && lastSession !== "last price" ? ` (${lastSession})` : ""}` }] : []),
147
+ formatted: `${money(lastPrint)} ${PRICE_UNIT}${lastSession && lastSession !== "last price" ? ` (${scenarioSpotStamp(lastSession, market.asOf)})` : ""}` }] : []),
141
148
  ...(position!.underlying ? [{ key: "shares", label: "Shares held", value: position!.underlying.quantity,
142
149
  formatted: `${position!.underlying.quantity.toLocaleString("en-US")} at ${money(position!.underlying.price)} ${PRICE_UNIT}` }] : []),
143
150
  { key: "asOf", label: "Valued at", value: new Date(position!.asOf).toISOString(), formatted: formatSpotTime(position!.asOf) },
@@ -167,7 +174,7 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
167
174
  ...Object.fromEntries(row.values.map((value, index) => [`date${index}`, value])) })) },
168
175
  ] : [],
169
176
  complete: scenario != null && errors.length === 0,
170
- unavailableSymbols: scenario ? [] : [symbol], errors,
177
+ unavailableSymbols: scenario ? [] : [symbol], errors, ...(fate?.empty ? { empty: fate.empty } : {}),
171
178
  metadata: { scenario, position, controls, market, ...(hedge ? { hedge } : {}), ...(csp ? { csp } : {}), ...(collar ? { collar } : {}),
172
179
  ...(position ? { spot: { price: position.spot, basis: spotBasis, valuedAt: new Date(position.asOf).toISOString() } } : {}),
173
180
  inputSource: suppliedInputs ? "user" : "market",
@@ -26,7 +26,7 @@ import { buildOptionQuoteKey, freshOptionQuote, OPTIONS_QUOTE_EXCHANGE } from ".
26
26
  import { liveScenarioPosition, scenarioLegContractSymbol } from "./live";
27
27
  import { ScenarioPayoffChart } from "./charts";
28
28
  import { ScenarioLegEditor, ScenarioSaveForm, ScenarioInputsForm } from "./editor";
29
- import { loadScenarioMarket, scenarioControlsFromSettings, scenarioPositionFromSettings, scenarioSpotBasis, suppliedScenarioInputs,
29
+ import { loadScenarioMarket, scenarioControlsFromSettings, scenarioPositionFromSettings, scenarioSpotBasis, scenarioSpotStamp, scenarioSpotStampVariants, suppliedScenarioInputs,
30
30
  type ScenarioMarketSnapshot } from "./client";
31
31
  import { scenarioFreshness } from "./freshness";
32
32
  import { cashSecuredPut, cspStatItems, loadNextEarningsDate } from "./csp";
@@ -294,11 +294,15 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
294
294
  const midLegs = live || !position ? 0 : position.legs.filter((leg) => leg.volatilitySource === "mid").length;
295
295
  // A seed starts at the spot its quote mids imply, so name the last print when it differs.
296
296
  const lastPrint = midLegs && (settings.spot == null || settings.spot === "") && market?.spot != null
297
- && money(market.spot) !== money(position!.spot) ? ` · last ${money(market.spot)}` : "";
297
+ && money(market.spot) !== money(position!.spot)
298
+ ? ` · ${(market.underlyingQuote ? extendedSession(market.underlyingQuote) : null) ?? "last"} ${money(market.spot)}` : "";
298
299
  const midVolatility = !midLegs ? "" : (midLegs === position!.legs.length ? " · IV from quote mid"
299
300
  : ` · IV from quote mid on ${midLegs} of ${position!.legs.length} legs`) + lastPrint;
300
301
  // 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.
301
- const spotText = position ? `spot ${money(position.spot)}${spotBasis && spotBasis !== "last price" ? ` ${spotBasis}` : ""}` : "";
302
+ // An extended-session print carries its own time in the stamp.
303
+ const spotStamp = position && spotBasis && spotBasis !== "last price" ? scenarioSpotStamp(spotBasis, position.asOf) : null;
304
+ const spotText = position ? `spot ${money(position.spot)}${spotStamp ? ` ${spotStamp}` : ""}` : "";
305
+ const spotTimed = !!spotStamp && spotStamp !== spotBasis;
302
306
  // A narrow row keeps the spot and its time before the rest.
303
307
  const shortSpotText = position ? `spot ${money(position.spot)} · ${formatSpotTime(live?.observedAt ?? position.asOf)}` : "";
304
308
  usePaneFooter("osa", () => ({ info: [
@@ -307,7 +311,7 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
307
311
  ...(hedge.error ? [{ id: "hedge-error", parts: [{ text: hedge.error, tone: "warning" as const }] }] : []),
308
312
  ...(live ? splitDelayedStatus({ id: "asof", parts: [{ text: `${live.basis} · ${spotText} · ${new Date(live.observedAt).toISOString().slice(11, 19)} UTC`, tone: "muted" }],
309
313
  variants: [[{ text: shortSpotText, tone: "muted" }]] })
310
- : position ? [{ id: "asof", parts: [{ text: `${spotText} · ${formatSpotTime(position.asOf)} · ${market?.source ? "market" : "input assumptions"}${midVolatility}`, tone: "muted" as const }],
314
+ : position ? [{ id: "asof", parts: [{ text: `${spotText}${spotTimed ? "" : ` · ${formatSpotTime(position.asOf)}`} · ${market?.source ? "market" : "input assumptions"}${midVolatility}`, tone: "muted" as const }],
311
315
  variants: [[{ text: shortSpotText, tone: "muted" as const }]] }] : []),
312
316
  ], hints }), [hints, position, market?.source, resource.loading, error, hedge.error, live?.basis, live?.observedAt, midVolatility, spotText, shortSpotText]);
313
317
  useScenarioEvidence({ scenario, view: tab, loading: !!resource.loading && !scenario, error: error ?? (snapshotErrors.join("; ") || null), notices });
@@ -337,7 +341,11 @@ export function OptionsScenarioPane({ width, height, focused }: PaneProps) {
337
341
  tone: cents(scenario.valuation.pnl) >= 0 ? "positive" : "negative" },
338
342
  // A spot other than the last price says what it is: the one the quote mids imply, or a typed what-if.
339
343
  { id: "spot", label: "Spot", value: money(scenario.position.spot),
340
- detail: spotBasis === "implied by option quotes" ? "per share, implied" : spotBasis === "typed" ? "per share, typed" : "per share" },
344
+ detail: spotBasis === "implied by option quotes" ? "per share, implied" : spotBasis === "typed" ? "per share, typed"
345
+ : spotTimed ? `per share, ${spotStamp}` : "per share",
346
+ // The band keeps its columns: the stamp's day, its time, then the stamp give way first.
347
+ ...(spotTimed ? { detailVariants: [...scenarioSpotStampVariants(spotBasis!, scenario.position.asOf)
348
+ .map((form) => `per share, ${form}`), "per share"] } : {}) },
341
349
  { id: "value", label: "Value", value: money(scenario.valuation.price), detail: valueUnit },
342
350
  { id: "max-profit", label: "Max profit", value: risk?.unlimitedProfit ? "Unlimited" : money(risk?.maxProfit ?? null) },
343
351
  { id: "max-loss", label: "Max loss", value: risk?.unlimitedLoss ? "Unlimited" : money(risk?.maxLoss ?? null) },
@@ -31,6 +31,7 @@ import { isPlainKeyboardEvent } from "../../../utils/keyboard";
31
31
  import { formatRelativeAge } from "../../../utils/datetime-format";
32
32
  import { displayWidth } from "../../../utils/format";
33
33
  import { requiredGloomberb } from "../../../utils/semver";
34
+ import { logFailure } from "../../../utils/log-failure";
34
35
  import { VERSION } from "../../../version";
35
36
  import { getCurrentPluginTarget, runsExternalPlugins } from "../../current-target";
36
37
  import { pluginSetupCommandId } from "../../registry/setup-command";
@@ -483,7 +484,7 @@ export function PluginMarketplacePane({ focused, width, height }: PaneProps) {
483
484
  void check(unlistedKey.split(","))
484
485
  // Offline, or a private repository this machine has no credentials for:
485
486
  // the row keeps whatever it knew, without an error in the way.
486
- .catch(() => ({}))
487
+ .catch(logFailure("plugin-marketplace.pane", "Check installed plugin remote heads failed", {}))
487
488
  .then((heads) => {
488
489
  if (cancelled) return;
489
490
  setRemoteHeads(heads);
@@ -638,8 +639,8 @@ export function PluginMarketplacePane({ focused, width, height }: PaneProps) {
638
639
  if (!confirmed) return;
639
640
  setBusy({ id: entry.id, verb: "removing" });
640
641
  setLastError(null);
641
- await host.deactivate(entry.id, entry.directory).catch(() => {});
642
- await manager.deactivate?.(entry.id).catch(() => {});
642
+ await host.deactivate(entry.id, entry.directory).catch(logFailure("plugin-marketplace.pane", "Deactivate plugin view before removal failed"));
643
+ await manager.deactivate?.(entry.id).catch(logFailure("plugin-marketplace.pane", "Deactivate plugin host before removal failed"));
643
644
  const result = await manager.remove(entry.directory!);
644
645
  setBusy(null);
645
646
  bump();
@@ -735,7 +736,7 @@ export function PluginMarketplacePane({ focused, width, height }: PaneProps) {
735
736
  }))}
736
737
  />
737
738
  ),
738
- }).catch(() => null);
739
+ }).catch(logFailure("plugin-marketplace.pane", "Choose starter pack failed", null));
739
740
  const pack = STARTER_PACKS.find((candidate) => candidate.id === id);
740
741
  if (pack) await applyPack(pack);
741
742
  }, [applyPack, currentPack, dialog, disabledPlugins, host]);
@@ -37,7 +37,7 @@ export const FILE_OPTION = {
37
37
  export const SYMBOL_LIST_HELP = {
38
38
  title: "Several symbols",
39
39
  lines: [
40
- "add and remove take any number of symbols split by spaces, commas or newlines, a --file, or piped input (or -). An entry of a list with commas or newlines that has spaces in it is a company's name when search finds one, read as its US listing first with a line under it saying which, and otherwise the symbols it holds. A CSV is read from its Symbol, Ticker or Code column. Without that header every cell is a symbol, unless names, prices or weights sit beside them: then the first column is read and the cells left out are named. Quote a list name with spaces.",
40
+ "add and remove take any number of symbols split by spaces, commas or newlines, a --file, or piped input (or -). An entry of a list with commas, semicolons or newlines that has spaces in it is a company's name when search finds one (philip morris intl is PM), read as its US listing first with a line under it saying which, else the symbols it holds when each is written as a symbol or has a quote (AAPL MSFT), and otherwise a name not found. A file or piped list reads each line as such an entry. A CSV is read from its Symbol, Ticker or Code column, else its Name or Company column as names. Without that header every cell is a symbol, unless names, prices or weights sit beside them: then the first column is read and the cells left out are named. Quote a list name with spaces.",
41
41
  "Each symbol gets a line, then a summary. Exit status 0 when at least one symbol was added (or removed) or already was, 1 when none resolved or the usage is wrong.",
42
42
  "A bare US-style symbol is not added when the listing it matched stopped trading, is abroad (DFS as DFS:LSE) or was taken by another company in the last two years (BRKL), or when nothing lists under it now and its company trades as another. Those are listed under Needs a choice, with the add for what their company trades as now and the listing that matched, which naming keeps; one symbol alone fails with the same. A symbol another company took longer ago says so under its line.",
43
43
  "add --dry-run looks every symbol up and prints the same lines without saving.",
@@ -218,15 +218,18 @@ export async function requireSymbolList(
218
218
  readEntries?: (entries: string[]) => Promise<string[]>,
219
219
  ): Promise<{ symbols: string[]; repeated: number; notes: string[]; single: string | null }> {
220
220
  try {
221
- let typed: string[] = [];
221
+ // The first entries read are what was typed; a file's and standard input's come after.
222
+ let typed: string[] | null = null;
222
223
  const list = await readSymbolList({ args, file, readEntries: async (entries) => {
223
- typed = readEntries ? await readEntries(entries) : splitSymbolWords(entries);
224
- return typed;
224
+ const read = readEntries ? await readEntries(entries) : splitSymbolWords(entries);
225
+ typed ??= read;
226
+ return read;
225
227
  } });
226
228
  if (list.symbols.length === 0) ctx.fail(usage);
227
229
  // One symbol typed alone keeps the single-symbol output and errors. It goes as typed, so a separator
228
230
  // left on it (`UPST;`) is named by the listing it resolves to.
229
- const single = file == null && !args.includes("-") && typed.length === 1 ? (args.length === 1 ? args[0]!.trim() : typed[0]!) : null;
231
+ const entries: string[] = typed ?? [];
232
+ const single = file == null && !args.includes("-") && entries.length === 1 ? (args.length === 1 ? args[0]!.trim() : entries[0]!) : null;
230
233
  return { ...list, single };
231
234
  } catch (error) {
232
235
  if (error instanceof SymbolListError) ctx.fail(error.message, usage);
@@ -106,11 +106,11 @@ export async function fetchPowerTickerScopes(symbol: string, tab: PowerTab, cont
106
106
  }
107
107
  const key = (query: unknown, scope: string) => `${scope}:${JSON.stringify(query)}`;
108
108
  export const loadPowerBoard = (filter: PowerFilter, scope: string, force = false, signal?: AbortSignal) =>
109
- loadCloudResource(powerBoardCache, key(filter, scope), () => fetchPowerBoard(filter, apiClient, signal), { force, validate: validatePowerBoard });
109
+ loadCloudResource(powerBoardCache, key(filter, scope), () => fetchPowerBoard(filter, apiClient, signal), { force, validate: validatePowerBoard, signal });
110
110
  export const loadPowerHistory = (filter: PowerFilter, scope: string, force = false) =>
111
111
  loadCloudResource(powerHistoryCache, key(filter, scope), () => fetchPowerHistory(filter), { force, validate: validatePowerHistory });
112
112
  export const loadPowerDetail = (id: string, scope: string, force = false, offset = 0, signal?: AbortSignal) => loadCloudResource(powerDetailCache, key({ id, offset }, scope),
113
113
  async () => { const value = validateDetail(await apiClient.getCloudPowerProject(id, { offset, limit: 100 }, signal));
114
114
  if (value.hasMore && value.nextOffset! <= offset) throw new Error("Project revision pagination did not advance.");
115
115
  if (value.project && value.project.id !== id || value.revisions.some((r) => r.id !== id)) throw new Error("Project evidence belongs to a different project.");
116
- return value; }, { force, validate: validateDetail });
116
+ return value; }, { force, validate: validateDetail, signal });
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import type { PowerBoard, PowerFilter, PowerHistory } from "../../../api-client/power";
3
3
  import { isAccessDenied } from "../../../api-client/errors";
4
4
  import { usePlanAccess } from "../../../api-client/plan-access";
5
- import { Button, ChartTableHeader, DataTableStackView, EmptyState, PaneStatusBody, QueryBar, useChartTableSelection, usePagedRows, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, useQueryBarSearch, useTableLoadMore, type DataTableCell, type PaneHint, type QueryBarFilter } from "../../../components";
5
+ import { Button, ChartTableHeader, DataTableStackView, EmptyState, QueryBar, useChartTableSelection, usePagedRows, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, useQueryBarSearch, useTableLoadMore, type DataTableCell, type PaneHint, type QueryBarFilter } from "../../../components";
6
6
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
7
7
  import { useAsyncResource, useAutoRefresh, usePaneSettingValue, usePluginAppActions, usePluginPaneState } from "../../../public/react";
8
8
  import { useThemeColors } from "../../../theme/theme-context";
@@ -97,6 +97,10 @@ function PowerView({ width, height, focused, scope, needsVerification }: PanePro
97
97
  }, [filter, scope, projectTab, snapshot]);
98
98
  const pages = usePagedRows(loader, { getId: (row) => row.id });
99
99
  const board = isAccessDenied(pages.error) || isAccessDenied(pages.moreError) ? undefined : pages.pages[0]?.payload;
100
+ // A tab or filter change starts its list over; the filter menus keep the last board's choices until the new one answers.
101
+ const lastFilters = useRef(board?.filters);
102
+ if (board) lastFilters.current = board.filters;
103
+ const filterChoices = board?.filters ?? lastFilters.current;
100
104
  const historyLoader = useCallback(async ({ offset, force }: { offset: number; force: boolean }) => {
101
105
  const result = snapshot?.history ? { payload: snapshot.history, stale: false, refreshError: null }
102
106
  : await loadPowerHistory({ ...filter, offset, limit: 500 }, scope, force);
@@ -157,7 +161,8 @@ function PowerView({ width, height, focused, scope, needsVerification }: PanePro
157
161
  const notices = [pages.moreError?.message, historyPages.moreError?.message, ...pages.pages.map((p) => p.refreshError), historyData?.refreshError,
158
162
  board && tab !== "capacity" ? powerCoverageNote(board.coverage) : null].filter((v): v is string => !!v);
159
163
  usePaneNoticeFooter({ registrationId: "power:notices", focused, notices: [...new Set(notices)], enabled: !openRow });
160
- const { strip, rows: tabRows } = usePaneTabs(board ? { tabs: [...POWER_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
164
+ const signInWall = !board && isCloudSessionRequired(error);
165
+ const { strip, rows: tabRows } = usePaneTabs(!signInWall ? { tabs: [...POWER_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
161
166
  const baseColumns = tab === "capacity" && context === "generation" ? GENERATION_COLUMNS : tab === "capacity" && context === "utility" ? UTILITY_COLUMNS : projectTab ? PROJECT_COLUMNS : tab === "history" ? HISTORY_COLUMNS : tab === "outcomes" ? RATE_COLUMNS : tab === "utilities" ? EXPOSURE_COLUMNS : COVERAGE_COLUMNS;
162
167
  const records = useMemo(() => rows.filter(isRecord), [rows]);
163
168
  const columns = useMemo(() => withoutQuietColumns(baseColumns, records, (r, id) => r.cells[id] ?? "", QUIET_COLUMNS), [baseColumns, records]);
@@ -167,10 +172,10 @@ function PowerView({ width, height, focused, scope, needsVerification }: PanePro
167
172
  const filters: QueryBarFilter[] = [
168
173
  ...(historical && ["queue", "history", "outcomes"].includes(tab) ? [{ id: "source", label: "History", value: sourceId, defaultValue: "", options: [{ value: "", label: "All benchmarks" }, { value: "us-lbnl-annual", label: "Annual benchmark" }, { value: "us-lbnl", label: "Project benchmark" }], onChange: setSourceId }] : []),
169
174
  ...(["loads", "utilities"].includes(tab) ? [{ id: "loadClass", label: "Loads", value: loadClass, defaultValue: "", options: [{ value: "", label: "All loads" }, { value: "datacenter", label: "Datacenters" }], onChange: setLoadClass }] : []),
170
- { id: "country", label: "Country", value: country, defaultValue: "", options: choices(board?.filters.countries), onChange: setCountry },
171
- { id: "region", label: "Region", value: region, defaultValue: "", options: choices(board?.filters.regions), onChange: setRegion },
172
- ...(!["loads", "utilities", "coverage"].includes(tab) ? [{ id: "fuel", label: "Fuel", value: fuel, defaultValue: "", options: choices(board?.filters.fuels), onChange: setFuel }] : []),
173
- ...(tab !== "coverage" ? [{ id: "status", label: "Status", value: status, defaultValue: "", options: choices(board?.filters.statuses), onChange: setStatus }] : []),
175
+ { id: "country", label: "Country", value: country, defaultValue: "", options: choices(filterChoices?.countries), onChange: setCountry },
176
+ { id: "region", label: "Region", value: region, defaultValue: "", options: choices(filterChoices?.regions), onChange: setRegion },
177
+ ...(!["loads", "utilities", "coverage"].includes(tab) ? [{ id: "fuel", label: "Fuel", value: fuel, defaultValue: "", options: choices(filterChoices?.fuels), onChange: setFuel }] : []),
178
+ ...(tab !== "coverage" ? [{ id: "status", label: "Status", value: status, defaultValue: "", options: choices(filterChoices?.statuses), onChange: setStatus }] : []),
174
179
  ...(["queue", "history", "outcomes"].includes(tab) ? [{ id: "historical", label: "Benchmark", kind: "toggle" as const, value: historical, defaultValue: false, onChange: setHistorical }] : []),
175
180
  ...(tab === "capacity" ? [{ id: "context", label: "Context", value: context, options: [{ value: "capacity", label: "Capacity" }, { value: "generation", label: "Generation" }, { value: "utility", label: "Utilities" }], onChange: setContext }] : []),
176
181
  ];
@@ -206,31 +211,31 @@ function PowerView({ width, height, focused, scope, needsVerification }: PanePro
206
211
  color: column.id === "name" || column.id === "utility" || column.id === "capacityMw" || column.id === "requestedMw" ? colors.textBright
207
212
  : ["location", "asOf", "observedAt", "scope", "proposedDate", "region", "country", "kind", "role", "period"].includes(column.id) ? colors.textDim : colors.text };
208
213
  };
209
- if (!board && isCloudSessionRequired(error)) return <SignInWall placement="power-signin" action="view power and interconnection data" needsVerification={needsVerification} />;
214
+ if (signInWall) return <SignInWall placement="power-signin" action="view power and interconnection data" needsVerification={needsVerification} />;
215
+ // The strip and the query bar stay through every load: a tab or filter change waits in the table's body.
210
216
  return <Box width={width} height={height} flexDirection="column">{strip}
211
- <PaneStatusBody loading={!board && pages.loading} error={!board ? error : null} subject="power and interconnection data">
212
- {board ? <DataTableStackView<PowerRow> columns={columns} items={items} getItemKey={rowId} focused={focused && !searchFocus.active}
213
- rootWidth={width} rootHeight={bodyHeight} scrollRef={scrollRef} onBodyScrollActivity={loadMore} resetScrollKey={`${tab}:${queryKey}`}
214
- selection={{ kind: "id", selectedId: selected?.id ?? null, getId: rowId, onChange: setSelectedId }}
215
- onActivate={(row) => row.locked ? upgrade() : setOpenId(row.id)} detailOpen={!!openRow} onBack={() => setOpenId(null)} detailTitle={openRow?.label}
216
- detailScrollRef={detailScrollRef} detailContent={openRow ? <PowerDetailView row={openRow} scope={scope} scrollRef={detailScrollRef} snapshot={!!snapshot} focused={focused} width={width} height={bodyHeight - 1} /> : null}
217
- renderCell={cell} freezeFirstColumn showHorizontalScrollbar selectedTextOverridesCellColor
218
- isNavigable={isRecord} renderSectionHeader={(row) => row.section ? { text: `${row.label} (${row.section.count})` } : null}
219
- sortColumnId={projectTab ? sort.column ?? null : null} sortDirection={sort.direction}
220
- isColumnSortable={(column) => projectTab && POWER_SORT_COLUMNS.includes(column.id)}
221
- onHeaderClick={projectTab ? (column) => { if (POWER_SORT_COLUMNS.includes(column)) setSort((old) => ({ column: column as PowerFilter["sort"], direction: old.column === column && old.direction === "desc" ? "asc" : "desc" })); } : undefined}
222
- rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={query}
223
- figures={projectTab && tab === "queue" && board.access === "full" ? queueFigures : undefined}
224
- chart={tab === "history" ? { series, ...chartLink, formatValue: (n) => `${powerNumber(n)} MW`, loading: historyPages.loading || historyPages.hasMore && !historyPages.moreError,
225
- empty: series.length ? undefined : historyPages.moreError ? "Full history could not load. Refresh to retry." : historyData?.payload.access === "preview" ? "Full history is available with Pro." : "History is accumulating from recorded snapshots.", remoteKind: "power-history" } : null} />}
226
- emptyStateTitle={tab === "history" ? historyPages.error?.message ?? "No recorded history for these filters." : "No reported records match these filters."}
227
- emptyContent={tickerMessage ? <Box paddingX={1} paddingY={1}>
228
- <EmptyState title={tickerMessage.title} hint={tickerMessage.hint || undefined}
229
- actions={tickerMessage.found.length ? tickerMessage.found.map((scope) => <Button key={`${scope.tab}:${scope.context}`} label={`Open ${scope.view}`}
230
- onPress={() => { if (scope.tab === "capacity") setContext(scope.context); setTab(scope.tab); }} />) : undefined} />
231
- </Box> : undefined}
232
- bodyAfter={locked && desktop ? <LockedOverlay rows={Math.min(3, locked)} text="Unlock full power data with Pro" onPress={upgrade} role="power-upgrade" /> : undefined}
233
- /> : null}
234
- </PaneStatusBody>
217
+ <DataTableStackView<PowerRow> columns={columns} items={board ? items : []} getItemKey={rowId} focused={focused && !searchFocus.active}
218
+ status={board ? undefined : { loading: pages.loading, error, subject: "power and interconnection data" }}
219
+ rootWidth={width} rootHeight={bodyHeight} scrollRef={scrollRef} onBodyScrollActivity={loadMore} resetScrollKey={`${tab}:${queryKey}`}
220
+ selection={{ kind: "id", selectedId: selected?.id ?? null, getId: rowId, onChange: setSelectedId }}
221
+ onActivate={(row) => row.locked ? upgrade() : setOpenId(row.id)} detailOpen={!!openRow} onBack={() => setOpenId(null)} detailTitle={openRow?.label}
222
+ detailScrollRef={detailScrollRef} detailContent={openRow ? <PowerDetailView row={openRow} scope={scope} scrollRef={detailScrollRef} snapshot={!!snapshot} focused={focused} width={width} height={bodyHeight - 1} /> : null}
223
+ renderCell={cell} freezeFirstColumn showHorizontalScrollbar selectedTextOverridesCellColor
224
+ isNavigable={isRecord} renderSectionHeader={(row) => row.section ? { text: `${row.label} (${row.section.count})` } : null}
225
+ sortColumnId={projectTab ? sort.column ?? null : null} sortDirection={sort.direction}
226
+ isColumnSortable={(column) => projectTab && POWER_SORT_COLUMNS.includes(column.id)}
227
+ onHeaderClick={projectTab ? (column) => { if (POWER_SORT_COLUMNS.includes(column)) setSort((old) => ({ column: column as PowerFilter["sort"], direction: old.column === column && old.direction === "desc" ? "asc" : "desc" })); } : undefined}
228
+ rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={query}
229
+ figures={projectTab && tab === "queue" && board?.access === "full" ? queueFigures : undefined}
230
+ chart={board && tab === "history" ? { series, ...chartLink, formatValue: (n) => `${powerNumber(n)} MW`, loading: historyPages.loading || historyPages.hasMore && !historyPages.moreError,
231
+ empty: series.length ? undefined : historyPages.moreError ? "Full history could not load. Refresh to retry." : historyData?.payload.access === "preview" ? "Full history is available with Pro." : "History is accumulating from recorded snapshots.", remoteKind: "power-history" } : null} />}
232
+ emptyStateTitle={tab === "history" ? historyPages.error?.message ?? "No recorded history for these filters." : "No reported records match these filters."}
233
+ emptyContent={tickerMessage ? <Box paddingX={1} paddingY={1}>
234
+ <EmptyState title={tickerMessage.title} hint={tickerMessage.hint || undefined}
235
+ actions={tickerMessage.found.length ? tickerMessage.found.map((scope) => <Button key={`${scope.tab}:${scope.context}`} label={`Open ${scope.view}`}
236
+ onPress={() => { if (scope.tab === "capacity") setContext(scope.context); setTab(scope.tab); }} />) : undefined} />
237
+ </Box> : undefined}
238
+ bodyAfter={locked && desktop ? <LockedOverlay rows={Math.min(3, locked)} text="Unlock full power data with Pro" onPress={upgrade} role="power-upgrade" /> : undefined}
239
+ />
235
240
  </Box>;
236
241
  }
@@ -1,3 +1,4 @@
1
+ import { unavailableText } from "../../../components/ui/status";
1
2
  import { getSharedMarketDataCoordinator, MarketDataCoordinator, resolveEntryValue } from "../../../market-data/coordinator";
2
3
  import type { InstrumentRef } from "../../../market-data/request-types";
3
4
  import type { DataProvider } from "../../../types/data-provider";
@@ -27,7 +28,7 @@ export async function loadReverseDcfInputs(
27
28
  const entry = await abortable(coordinator.loadSnapshot(request.instrument, { forceRefresh: request.forceRefresh }), request.signal, CANCELLED);
28
29
  const financials = resolveEntryValue(entry);
29
30
  return { financials, stale: !!entry.error || !!financials?.fundamentals?.stale || (entry.staleAt != null && entry.staleAt <= now),
30
- error: entry.error?.message ?? (financials ? null : "Fundamentals unavailable"), fetchedAt: entry.fetchedAt ?? now };
31
+ error: entry.error?.message ?? (financials ? null : unavailableText("Fundamentals")), fetchedAt: entry.fetchedAt ?? now };
31
32
  } catch (error) {
32
33
  if (request.signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw abortError(CANCELLED);
33
34
  return { financials: null, stale: false, error: errorMessage(error), fetchedAt: now };
@@ -1,3 +1,4 @@
1
+ import { unavailableText } from "../../../components/ui/status";
1
2
  import { atRegularSessionPrice } from "../../../market-data/quotes/regular-session-valuation";
2
3
  import type { Fundamentals, TickerFinancials } from "../../../types/financials";
3
4
  import { enterpriseValueCheck, enterpriseValueLabel, enterpriseValueReconciliationNote, enterpriseValueWithdrawn,
@@ -152,10 +153,10 @@ export function projectReverseDcf(
152
153
  }
153
154
  // A withdrawn EV is unavailable here as in `valuation`, which says why.
154
155
  if (enterpriseValueWithdrawn(fundamentals)) {
155
- return { ...empty, error: enterpriseValueReconciliationNote(fundamentals) ?? "Enterprise value unavailable." };
156
+ return { ...empty, error: enterpriseValueReconciliationNote(fundamentals) ?? unavailableText("Enterprise value") };
156
157
  }
157
- if (enterpriseValue == null || !(enterpriseValue > 0)) return { ...empty, error: "Enterprise value unavailable." };
158
- if (freeCashFlow == null) return { ...empty, error: "Free cash flow unavailable." };
158
+ if (enterpriseValue == null || !(enterpriseValue > 0)) return { ...empty, error: unavailableText("Enterprise value") };
159
+ if (freeCashFlow == null) return { ...empty, error: unavailableText("Free cash flow") };
159
160
  if (!(freeCashFlow > 0)) return { ...empty, error: "Free cash flow over the last twelve months is negative, so no growth rate prices it." };
160
161
  const implied = solveImpliedGrowth(enterpriseValue, freeCashFlow, options.discountRate, TERMINAL_GROWTH);
161
162
  return {
@@ -1,3 +1,4 @@
1
+ import { unavailableText } from "../../../components/ui/status";
1
2
  import { getSharedMarketDataCoordinator, MarketDataCoordinator, resolveEntryValue } from "../../../market-data/coordinator";
2
3
  import type { InstrumentRef } from "../../../market-data/request-types";
3
4
  import type { ManualChartResolution } from "../../../time-series/resolution";
@@ -66,7 +67,7 @@ export async function loadSeasonalityHistory(
66
67
  granularity: "resolution", resolution: SEASONALITY_HISTORY_RESOLUTION[cadence] }, { forceRefresh: request.forceRefresh }), request.signal, CANCELLED);
67
68
  const history = resolveEntryValue(entry) ?? [];
68
69
  return { history, stale: !!entry.error || (entry.staleAt != null && entry.staleAt <= now),
69
- error: entry.error?.message ?? (history.length ? null : `${cadence === "daily" ? "Daily" : "Monthly"} price history unavailable`),
70
+ error: entry.error?.message ?? (history.length ? null : unavailableText(cadence === "daily" ? "Daily price history" : "Monthly price history")),
70
71
  fetchedAt: entry.fetchedAt ?? now };
71
72
  } catch (error) {
72
73
  if (request.signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw abortError(CANCELLED);