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
@@ -2,10 +2,19 @@ import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import { resolveHeadlessInstrument } from "../shared/headless-market-data";
3
3
  import { createRealizedVolatilityDependencies, loadRealizedVolatilityHistory } from "../realized-vol/client";
4
4
  import { createHvDependencies, loadIvHistory, loadIvScreen, loadScreenVolatilities } from "./client";
5
- import { formatPoints, formatRank, formatSpot, formatStat, formatVol, readingLabel, sharedDates, skewHeader, VCA_DEFINITIONS, verdictLabel } from "./format";
5
+ import { formatPoints, formatRank, formatSpot, formatStat, formatVol, IMPLIED_VOL_WORDS, readingLabel, sharedDates, skewHeader, VCA_DEFINITIONS, verdictLabel } from "./format";
6
6
  import { HV_WINDOWS, type HvWindow, type IvLookback, type IvStatRow, projectIvHistory, projectRichCheap, type RichCheapRow, richCheapDates, screenCoverage, VCA_LIMIT, VCA_PRESETS } from "./model";
7
7
  import { vcaUniverse } from "./universe";
8
- import { listingFateNotes, loadListingFate, MovedSymbolError } from "../../../tickers/listing-fate";
8
+ import { offsetText, releaseOffsetRows } from "./events";
9
+ import { loadMacroReleases } from "../macro-day/client";
10
+ import { MACRO_EVENT_KINDS, MACRO_EVENT_LABELS, MACRO_RELEASE_SOURCES, staleMacroReleasesNotice, type MacroEventKind, type MacroRelease,
11
+ type MacroReleaseList } from "../macro-day/releases";
12
+ import { lookupScheduledDates } from "../alerts/scheduled";
13
+ import { fetchCpiBoard } from "../cpi/client";
14
+ import { fetchRatePath } from "../rate-path/client";
15
+ import type { HeadlessPaneContext } from "../../../types/headless";
16
+ import { zonedDateKey } from "../../../utils/zoned-date-time";
17
+ import { listingFateNotes, listingFateReport, loadListingFate, MovedSymbolError } from "../../../tickers/listing-fate";
9
18
  import { startTickerChangeLookup } from "../../../tickers/ticker-changes";
10
19
 
11
20
  const METHODOLOGY = "docs/research-data.md#implied-volatility-history";
@@ -17,23 +26,33 @@ export const ivHistoryHeadless: HeadlessPaneDefinition<"bundle"> = {
17
26
  usListingsOnly: "Implied volatility history",
18
27
  freshness: { status: "not-a-feed", basis: "daily implied volatility", cadence: "daily", observedKey: "date", oldest: null },
19
28
  describe: (args) => `HIVG ${args.symbols[0] ?? ""}`,
20
- discovery: { aliases: ["HIVG"], screenshotReadiness: "live-dom", dataRequirements: ["Cloud stored implied volatility", "Daily price history"],
29
+ discovery: { aliases: ["HIVG"], suggestFor: IMPLIED_VOL_WORDS, screenshotReadiness: "live-dom", dataRequirements: ["Cloud stored implied volatility", "Daily price history"],
21
30
  limitations: ["History starts February 2024 (daily option trade closes).", "Rank and percentile use trade-close readings only.",
22
31
  "IV1Y needs long-dated trades: it can start later, has gaps, and ranks from 60 prior sessions.",
23
32
  "skew25d (put-call) and rr25d (call-put) come from the daily quote captures and are blank before the first one."] },
24
33
  options: [
25
34
  { key: "lookback", type: "enum", values: [{ value: "1Y" }, { value: "2Y" }, { value: "ALL" }], defaultValue: "1Y", description: "Visible history" },
26
35
  { key: "hvWindow", type: "enum", values: HV_WINDOWS.map((window) => ({ value: String(window) })), defaultValue: "20", description: "Realized volatility window in sessions" },
36
+ { key: "event", type: "enum", aliases: ["release"], values: MACRO_EVENT_KINDS.map((value) => ({ value, ...value === "jobs" ? { aliases: ["nfp", "payrolls"] } : {} })),
37
+ description: "One row per release of this kind whose session at --offset has closed (the next scheduled FOMC or CPI included), in place of the daily history",
38
+ example: "--event fomc --offset -6", shot: { reportOnly: "lists the readings around past release days" } },
39
+ { key: "offset", type: "integer", minimum: -60, maximum: 60, defaultValue: 0,
40
+ description: "Sessions from each release with --event: 0 is the first session on or after the release day, -6 the sixth session before that",
41
+ example: "--event cpi --offset -1", shot: { reportOnly: "picks the session read around each release" } },
27
42
  ],
28
43
  async load(args, ctx) {
44
+ const event = typeof args.options.event === "string" && args.options.event ? args.options.event as MacroEventKind : null;
45
+ const offset = Number(args.options.offset) || 0;
46
+ if (!event && offset !== 0) throw new Error("--offset counts sessions from a release day: add --event fomc, cpi or jobs.");
29
47
  const instrument = await resolveHeadlessInstrument(ctx, args.symbols[0]!);
30
48
  // What became of the symbol, as `quote` reads it, looked up beside the figures: FB's are the fund listed
31
49
  // there now, TWTR stopped trading, and HFC, which became DINO, has none of its own.
32
50
  const changes = startTickerChangeLookup(ctx.marketData, instrument.symbol, instrument.exchange);
33
51
  const quote = ctx.marketData.getQuote(instrument.symbol, instrument.exchange ?? "").catch(() => null);
34
- const [payload, prices] = await Promise.all([
52
+ const [payload, prices, releases] = await Promise.all([
35
53
  loadIvHistory(instrument.symbol, { signal: ctx.signal }, ctx.apiClient),
36
54
  loadRealizedVolatilityHistory({ instrument, signal: ctx.signal }, createRealizedVolatilityDependencies(ctx.marketData)),
55
+ event ? eventReleases(event, ctx) : null,
37
56
  ]);
38
57
  const fate = await loadListingFate(instrument, ctx.marketData, { quote, changes });
39
58
  if (fate.moved) throw new MovedSymbolError(fate.moved);
@@ -57,11 +76,47 @@ export const ivHistoryHeadless: HeadlessPaneDefinition<"bundle"> = {
57
76
  const skewColumns = [{ key: "skew25d", header: skewHeader("skew25d", "put-call"), format: points },
58
77
  { key: "rr25d", header: skewHeader("rr25d", "call-put"), format: points }];
59
78
  const served = model.iv30.length > 0 || prices.history.length > 0;
60
- const fateNotes = listingFateNotes(fate, { served });
61
79
  // With nothing served, the fate is why: no "not a ticker" or missing-history errors beside it.
62
- const errors = !served && fateNotes.length > 0 ? []
63
- : [prices.error, ...model.warnings].filter((value): value is string => !!value);
80
+ const { notes: fateNotes, errors } = listingFateReport(fate, { served },
81
+ [prices.error, ...model.warnings].filter((value): value is string => !!value));
64
82
  const notes = [...fateNotes, ...[model.oneYearNote, model.skewNote].filter((note): note is string => !!note)];
83
+ if (event && releases) {
84
+ const lookback = String(args.options.lookback) as IvLookback;
85
+ const label = MACRO_EVENT_LABELS[event];
86
+ const offsets = releaseOffsetRows(model, releases.list.releases, event, offset, {
87
+ lookback, readThrough: payload.coverage?.backfilledThrough ?? null });
88
+ const plural = (count: number, one: string, many: string) => count === 1 ? one : many;
89
+ const eventNotes = [
90
+ offsets.beforeLookback > 0
91
+ ? `${offsets.beforeLookback} earlier ${label} ${plural(offsets.beforeLookback, "release is", "releases are")} outside the ${lookback} lookback; --lookback ALL adds ${plural(offsets.beforeLookback, "it", "them")}.` : null,
92
+ // Said once the lookback says nothing: the history's own start is then where the rows stop.
93
+ offsets.beforeHistory > 0 && offsets.beforeLookback === 0
94
+ ? `${offsets.beforeHistory} earlier ${label} ${plural(offsets.beforeHistory, "release predates", "releases predate")} the IV history, which starts ${offsets.historyStart}.` : null,
95
+ offsets.pending.length > 0
96
+ ? `Left out ${label} ${offsets.pending.join(", ")}: no closing reading yet for ${plural(offsets.pending.length, "its session", "their sessions")} at --offset ${offset}.` : null,
97
+ releases.upcomingNote,
98
+ staleMacroReleasesNotice(releases.list),
99
+ ].filter((note): note is string => !!note);
100
+ const eventColumns = [{ key: "releaseDate", header: "Release date" }, { key: "session", header: "Session" },
101
+ ...historyColumns.slice(1), ...skewColumns];
102
+ return {
103
+ sections: [{
104
+ title: `${label} releases, ${offsetText(offset)}`,
105
+ // The export names the release and the offset on every row, so files of several offsets or releases join.
106
+ columns: [{ key: "release", header: "Release" }, eventColumns[0]!, { key: "offset", header: "Offset (sessions)" }, ...eventColumns.slice(1)],
107
+ rows: offsets.rows.map(({ date, ...row }) => ({ ...row, session: date, release: label })),
108
+ textColumns: offsets.rows.some((row) => row.skew25d != null) ? eventColumns : eventColumns.slice(0, -skewColumns.length),
109
+ }],
110
+ complete: payload.status === "ready" && errors.length === 0,
111
+ unavailableSymbols: offsets.rows.some((row) => row.iv30 != null) ? [] : [instrument.symbol], errors,
112
+ ...(notes.length + eventNotes.length > 0 ? { notes: [...notes, ...eventNotes] } : {}),
113
+ // Dated by the history read, not by the newest release's session: an old release is no stale reading.
114
+ ...(model.asOf ? { freshness: { asOf: model.asOf } } : {}),
115
+ metadata: { status: payload.status, coverage: payload.coverage, event, offset, releasesThrough: releases.list.coveredThrough,
116
+ nextRelease: releases.upcoming,
117
+ releaseSource: MACRO_RELEASE_SOURCES[event], unit: "decimal annualized volatility", methodology: METHODOLOGY },
118
+ };
119
+ }
65
120
  return {
66
121
  sections: [
67
122
  { title: "Statistics", columns: [
@@ -153,3 +208,32 @@ export const ivScreenHeadless: HeadlessPaneDefinition<"bundle"> = {
153
208
  };
154
209
  },
155
210
  };
211
+
212
+ /**
213
+ * The releases of `event` HIVG reads around: the days MDAY studies (the list
214
+ * the app ships with, extended by Gloom Cloud's), and the next one scheduled,
215
+ * from the CPI board's release calendar or the FOMC meetings WIRP prices, as
216
+ * scheduled alerts read them. Payrolls have no schedule to read.
217
+ */
218
+ async function eventReleases(event: MacroEventKind, ctx: HeadlessPaneContext): Promise<{
219
+ list: MacroReleaseList; upcoming: string | null; upcomingNote: string | null;
220
+ }> {
221
+ const label = MACRO_EVENT_LABELS[event];
222
+ const [published, next] = await Promise.all([
223
+ loadMacroReleases({}, () => ctx.apiClient.getCloudMacroReleaseDays()),
224
+ event === "jobs" ? null : lookupScheduledDates([{ kind: "macro", event }], {
225
+ earnings: async () => [],
226
+ cpi: () => fetchCpiBoard(ctx.apiClient),
227
+ fomc: async () => (await fetchRatePath(ctx.apiClient)).meetings,
228
+ }, zonedDateKey(Date.now(), "America/New_York")).then((dates) => dates.get(`macro:${event}`) ?? { date: null }),
229
+ ]);
230
+ const upcoming = next && "date" in next ? next.date : null;
231
+ const listed = upcoming != null && published.releases.some((release) => release.kind === event && release.date === upcoming);
232
+ const releases: MacroRelease[] = upcoming && !listed ? [...published.releases, { kind: event, date: upcoming }] : published.releases;
233
+ return {
234
+ list: { ...published, releases },
235
+ upcoming,
236
+ upcomingNote: next == null ? `The next ${label} release is not scheduled here, so it has no row yet.`
237
+ : "error" in next ? `The next ${label} date could not be loaded (${next.error}), so it has no row.` : null,
238
+ };
239
+ }
@@ -39,7 +39,7 @@ export function JodiPane({ focused, width, height }: PaneProps) {
39
39
  const balances = resource.data;
40
40
  const gas = balances?.gas ?? null;
41
41
  const [storedTab, setTab] = usePluginPaneState<string>("commodity", "oil");
42
- const tab: BalanceCommodity = gas && storedTab === "gas" ? "gas" : "oil";
42
+ const tab: BalanceCommodity = storedTab === "gas" ? "gas" : "oil";
43
43
  const board = tab === "gas" ? gas : balances?.oil ?? null;
44
44
  const rows = board?.rows ?? EMPTY_ROWS;
45
45
  const [selectedId, setSelectedId] = usePluginPaneState<string | null>("selected", null);
@@ -54,7 +54,7 @@ export function JodiPane({ focused, width, height }: PaneProps) {
54
54
  const selectTab = useCallback((value: string) => {
55
55
  setTab(value === "gas" ? "gas" : "oil");
56
56
  }, [setTab]);
57
- const { strip: tabs, rows: tabRows } = usePaneTabs(gas ? {
57
+ const { strip: tabs, rows: tabRows } = usePaneTabs({
58
58
  tabs: TABS,
59
59
  activeValue: tab,
60
60
  onSelect: selectTab,
@@ -62,7 +62,7 @@ export function JodiPane({ focused, width, height }: PaneProps) {
62
62
  compact: true,
63
63
  variant: "underline",
64
64
  queryBarWidth: width,
65
- } : null);
65
+ });
66
66
  const info = useMemo(
67
67
  () => (resource.error || !board ? [] : [{ id: "month", parts: [{ text: formatBalanceMonth(board.month) }] }]),
68
68
  [board, resource.error],
@@ -139,7 +139,7 @@ export function JodiPane({ focused, width, height }: PaneProps) {
139
139
  onRootKeyDown={onRootKeyDown}
140
140
  renderCell={renderCell}
141
141
  selectedTextOverridesCellColor
142
- emptyStateTitle="No balances for this month."
142
+ emptyStateTitle={board ? "No balances for this month." : tab === "gas" ? "Gas balances unavailable." : "Oil balances unavailable."}
143
143
  />
144
144
  </Box>
145
145
  );
@@ -5,6 +5,7 @@ import type {
5
5
  CloudSessionMoversSide,
6
6
  } from "../../../api-client/market-movers";
7
7
  import type { DataProvider } from "../../../types/data-provider";
8
+ import { logFailure } from "../../../utils/log-failure";
8
9
  import {
9
10
  CATEGORY_MAP,
10
11
  moverRegularSession,
@@ -63,7 +64,7 @@ async function hydrateTrending(
63
64
  if (provider.getQuotesBatch) {
64
65
  const results = await provider.getQuotesBatch(
65
66
  symbols.map((symbol) => ({ symbol, exchange: "" })),
66
- ).catch(() => []);
67
+ ).catch(logFailure("market-movers", "Trending quote batch failed", []));
67
68
  for (const result of results) {
68
69
  if (result.quote) bySymbol.set(result.target.symbol, screenerQuoteFromQuote(result.target.symbol, result.quote));
69
70
  }
@@ -72,8 +73,8 @@ async function hydrateTrending(
72
73
  try {
73
74
  const quote = await provider.getQuote(symbol, "");
74
75
  bySymbol.set(symbol, screenerQuoteFromQuote(symbol, quote));
75
- } catch {
76
- // Missing quotes are omitted from a ranked list.
76
+ } catch (error) {
77
+ logFailure("market-movers", "Trending quote failed")(error);
77
78
  }
78
79
  }));
79
80
  }
@@ -1,6 +1,7 @@
1
1
  import type { ScreenerQuote } from "../../../api-client/market-discovery";
2
2
  export type { ScreenerQuote } from "../../../api-client/market-discovery";
3
3
  import { resolveCurrencyUnit } from "../../../utils/currency-units";
4
+ import { logFailure } from "../../../utils/log-failure";
4
5
  import type { PluginPersistence } from "../../../types/plugin";
5
6
  import { apiClient } from "../../../api-client";
6
7
  import type {
@@ -324,7 +325,7 @@ async function bestEffortMarketMetadata(
324
325
  let timeout: ReturnType<typeof setTimeout> | undefined;
325
326
  try {
326
327
  return await Promise.race([
327
- request.then((result) => result.data).catch(() => []),
328
+ request.then((result) => result.data).catch(logFailure("market-movers", "Market metadata failed", [])),
328
329
  new Promise<ScreenerQuote[]>((resolve) => {
329
330
  timeout = setTimeout(() => resolve([]), waitMs);
330
331
  }),
@@ -392,8 +393,8 @@ export async function fetchPreferredMarketMovers(
392
393
  stale: response.stale === true || response.data.stale === true,
393
394
  };
394
395
  }
395
- } catch {
396
- // The public backend snapshot remains available when live rankings fail.
396
+ } catch (error) {
397
+ logFailure("market-movers", "Live rankings failed")(error);
397
398
  }
398
399
 
399
400
  const fallback = await marketMetadata;
@@ -80,7 +80,7 @@ export const notesCliCommand: CliCommandDef = {
80
80
  });
81
81
  return;
82
82
  }
83
- warnings.push(...result.skipped.map(describeExportSkip));
83
+ warnings.push(...(result.warnings ?? []), ...result.skipped.map(describeExportSkip));
84
84
  ctx.printResult({
85
85
  data: {
86
86
  dir: result.dir,
@@ -1,4 +1,5 @@
1
1
  import { errorMessage } from "../../../utils/errors";
2
+ import { logFailure } from "../../../utils/log-failure";
2
3
  import type { NoteFileEntry, QuickNoteEntry } from "./files";
3
4
  import type { NotesStore } from "./store";
4
5
  import { noteOwnerKey, splitNoteKey } from "./store";
@@ -25,6 +26,8 @@ export interface NotesExportResult {
25
26
  /** The sources whose notes were written, in the order given. */
26
27
  exported: string[];
27
28
  skipped: NotesExportSkip[];
29
+ /** Notes were written, but some descriptive metadata could not be read. */
30
+ warnings?: string[];
28
31
  }
29
32
 
30
33
  function safeFileName(value: string): string {
@@ -46,15 +49,21 @@ export async function exportNotesToDirectory(
46
49
  let files = 0;
47
50
  const exported: string[] = [];
48
51
  const skipped: NotesExportResult["skipped"] = [];
52
+ const warnings: string[] = [];
49
53
  for (const source of sources) {
50
54
  const folder = sources.length > 1 ? `${dir}/${safeFileName(source.label)}` : dir;
51
55
  let entries: NoteFileEntry[];
52
56
  let quick: QuickNoteEntry[];
57
+ let titlesWarning: string | null = null;
53
58
  const unreadable: Array<{ key: string; error: unknown }> = [];
54
59
  try {
55
60
  [entries, quick] = await Promise.all([
56
61
  source.store.list({ strict: true, skipped: (key, error) => unreadable.push({ key, error }) }),
57
- source.store.loadQuickNotesIndex(),
62
+ source.store.loadQuickNotesIndex({ strict: true }).catch((error) => {
63
+ logFailure("notes.export", "Read quick note titles for export failed")(error);
64
+ titlesWarning = `Quick notes from ${source.label} were exported using ids because their titles could not be read (${errorMessage(error)}).`;
65
+ return [];
66
+ }),
58
67
  ]);
59
68
  } catch (error) {
60
69
  skipped.push({ label: source.label, reason: errorMessage(error) });
@@ -74,9 +83,10 @@ export async function exportNotesToDirectory(
74
83
  await writeFile(path, entry.text, "utf-8");
75
84
  files += 1;
76
85
  }
86
+ if (titlesWarning && entries.some((entry) => splitNoteKey(entry.key).kind === "quick")) warnings.push(titlesWarning);
77
87
  exported.push(source.label);
78
88
  }
79
- return { dir, files, exported, skipped };
89
+ return { dir, files, exported, skipped, ...(warnings.length > 0 ? { warnings } : {}) };
80
90
  }
81
91
 
82
92
  /** One line per skip, as `notes export` warns and the Export Notes command reports. */
@@ -86,12 +96,13 @@ export function describeExportSkip(skip: NotesExportSkip): string {
86
96
  : `Skipped the ${skip.label} note ${skip.note}: it could not be read (${skip.reason}).`;
87
97
  }
88
98
 
89
- /** The Export Notes command's notice: the count, then what was skipped. */
99
+ /** The Export Notes command's notice: the count, then warnings and what was skipped. */
90
100
  export function exportNotice(result: NotesExportResult): { body: string; type: "success" | "error" } {
91
101
  const exported = `Exported ${result.files} ${result.files === 1 ? "note" : "notes"} to ${result.dir}`;
92
- if (result.skipped.length === 0) return { body: exported, type: "success" };
93
- const shown = result.skipped.slice(0, 3).map(describeExportSkip);
94
- const more = result.skipped.length - shown.length;
102
+ const issues = [...(result.warnings ?? []), ...result.skipped.map(describeExportSkip)];
103
+ if (issues.length === 0) return { body: exported, type: "success" };
104
+ const shown = issues.slice(0, 3);
105
+ const more = issues.length - shown.length;
95
106
  return {
96
107
  body: [`${exported}.`, ...shown, ...(more > 0 ? [`${more} more could not be read.`] : [])].join(" "),
97
108
  type: "error",
@@ -29,9 +29,10 @@ export interface NotesFilesIO {
29
29
  * Every note's key (its `.md` file name without the extension) and when it
30
30
  * was last written. A folder that does not exist yet, or a note gone before
31
31
  * it was looked at, lists nothing. Any other failure is logged and that part
32
- * left out, or, with `strict`, rejects.
32
+ * left out, or, with `strict`, rejects. A `skipped` callback receives an
33
+ * unreadable note instead of rejecting the whole listing.
33
34
  */
34
- listKeys(options?: { strict?: boolean }): Promise<Array<[string, number]>>;
35
+ listKeys(options?: NotesListOptions): Promise<Array<[string, number]>>;
35
36
  }
36
37
 
37
38
  const QUICK_NOTES_INDEX_FILE = "__quick-notes-index__.json";
@@ -108,8 +109,9 @@ export function diskNotesFilesIO(dataDir: string): NotesFilesIO {
108
109
  } catch (error) {
109
110
  // A note deleted since the folder was listed is simply gone.
110
111
  if ((error as NodeJS.ErrnoException)?.code === "ENOENT") continue;
111
- if (options?.strict) throw error;
112
+ if (options?.strict && !options.skipped) throw error;
112
113
  logFailure("notes.files", "Read when a note was last written failed")(error);
114
+ options?.skipped?.(name.slice(0, -3), error);
113
115
  }
114
116
  }
115
117
  return keys;
@@ -200,7 +202,16 @@ export function remoteNotesFilesIO(
200
202
  read: async (file) => await invoke("read", { dataDir, file }) as string | null,
201
203
  write: async (file, text) => { await invoke("write", { dataDir, file, text }); },
202
204
  delete: async (file) => { await invoke("delete", { dataDir, file }); },
203
- listKeys: async (options) => await invoke("listKeys", { dataDir, ...(options?.strict ? { strict: true } : {}) }) as Array<[string, number]>,
205
+ async listKeys(options) {
206
+ const result = await invoke("listKeys", {
207
+ dataDir, ...(options?.strict ? { strict: true } : {}),
208
+ ...(options?.skipped ? { reportSkipped: true } : {}),
209
+ });
210
+ if (!options?.skipped) return result as Array<[string, number]>;
211
+ const { keys, skipped } = result as { keys: Array<[string, number]>; skipped: Array<{ key: string; reason: string }> };
212
+ for (const { key, reason } of skipped) options.skipped(key, new Error(reason));
213
+ return keys;
214
+ },
204
215
  };
205
216
  }
206
217
 
@@ -245,7 +256,7 @@ export class NotesFiles implements NotesStore {
245
256
 
246
257
  /** Every stored note, ticker notes and quick notes alike, newest first. */
247
258
  async list(options: NotesListOptions = {}): Promise<NoteFileEntry[]> {
248
- const { entries } = await this.readEach(await this.io.listKeys({ strict: options.strict }), options.skipped);
259
+ const { entries } = await this.readEach(await this.io.listKeys(options), options.skipped);
249
260
  return entries.sort((left, right) => right.updatedAt - left.updatedAt);
250
261
  }
251
262
 
@@ -4,7 +4,7 @@ import { buildOptionsKey, resolveEntryData } from "../../../market-data/selector
4
4
  import type { OptionsChain } from "../../../types/financials";
5
5
  import { normalizeSymbol, parsePublicTickerKey } from "../../../utils/exchanges";
6
6
  import { daysToExpiryFrom, volatilityTermSlope, type ExpectedMove } from "../shared/volatility";
7
- import { buildSurfaceExpiry, evaluateSurfaceSmile, type SurfaceExpiry } from "../vol-surface/model";
7
+ import { buildSurfaceExpiry, type SurfaceExpiry } from "../vol-surface/model";
8
8
  import type { YieldPoint } from "../yield-curve/treasury-data";
9
9
 
10
10
  export interface OptionsEnrichmentSelection {
@@ -97,8 +97,8 @@ export function optionsEnrichmentSelectionIssue(input: OptionsEnrichmentSelectio
97
97
 
98
98
  /**
99
99
  * Models kept between projections of one selection. The adjacent expiry does
100
- * not stream, so a live re-projection refits only the selected smile and reads
101
- * the adjacent ATM level off its existing fit at the current spot.
100
+ * not stream, so a live re-projection refits only the selected smile and keeps
101
+ * the adjacent fit, whose ATM-forward level does not move with the spot.
102
102
  */
103
103
  export interface OptionsEnrichmentCache {
104
104
  neighbour?: { chain: OptionsChain; curve: readonly YieldPoint[] | undefined; model: SurfaceExpiry };
@@ -134,17 +134,18 @@ export function projectOptionsEnrichment(input: OptionsEnrichmentProjection, cac
134
134
  else if (neighbourChain) {
135
135
  const cached = cache?.neighbour?.chain === neighbourChain && cache.neighbour.curve === input.curve
136
136
  ? cache.neighbour.model : null;
137
- const neighbour = cached ? { ...cached, atmIV: evaluateSurfaceSmile(cached, input.spot) }
138
- : buildSurfaceExpiry({ chain: neighbourChain, expiration: neighbourExpiration, spot: input.spot,
139
- curve: input.curve ?? [], now: input.now, source: input.neighbourEntry?.source });
137
+ const neighbour = cached ?? buildSurfaceExpiry({ chain: neighbourChain, expiration: neighbourExpiration, spot: input.spot,
138
+ curve: input.curve ?? [], now: input.now, source: input.neighbourEntry?.source });
140
139
  if (cache && !cached) cache.neighbour = { chain: neighbourChain, curve: input.curve, model: neighbour };
141
140
  result.rateAsOf = [...new Set([...result.rateAsOf, ...neighbour.rateAsOf])];
142
141
  result.warnings.push(...neighbour.warnings
143
142
  .filter((warning) => !(input.curveLoading && warning === "Treasury rate unavailable"))
144
143
  .map((warning) => `Adjacent expiry: ${warning}`));
145
- if (selected.atmIV != null && neighbour.atmIV != null) {
146
- Object.assign(result, optionsEnrichmentTermSlope({ years: selected.years, volatility: selected.atmIV },
147
- { years: neighbour.years, volatility: neighbour.atmIV }));
144
+ // The slope runs between the two expiries' IVs at their forwards, as OVDV's does, so a print
145
+ // against a closed session's chain does not tilt it.
146
+ if (selected.skew.atm != null && neighbour.skew.atm != null) {
147
+ Object.assign(result, optionsEnrichmentTermSlope({ years: selected.years, volatility: selected.skew.atm },
148
+ { years: neighbour.years, volatility: neighbour.skew.atm }));
148
149
  if (new Date(selected.asOf!).toISOString().slice(0, 10) !== new Date(neighbour.asOf!).toISOString().slice(0, 10)) {
149
150
  result.warnings.push("Term slope uses option observations from different dates");
150
151
  }
@@ -1,3 +1,4 @@
1
+ import { getExtendedSessionDisplay, type ExtendedSession } from "../../../market-data/market/status";
1
2
  import { getPublishedUsEquitySession } from "../../../market-data/published-us-sessions";
2
3
  import type { OptionsChain, Quote } from "../../../types/financials";
3
4
  import { zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
@@ -82,10 +83,75 @@ export function formatSpotTime(time: number): string {
82
83
  return `${WEEKDAYS[date.getUTCDay()]} ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]} ${date.toISOString().slice(11, 16)} UTC`;
83
84
  }
84
85
 
85
- /** How a footer names the spot: `close Mon 12 Oct 20:00 UTC`, `pre-market Tue 13 Oct 12:21 UTC`, `last Tue 13 Oct 14:05 UTC`. */
86
- export function modelSpotLabel(spot: Pick<OptionModelSpot, "basis" | "session" | "observedAt">): string {
87
- const word = spot.basis === "close" ? "close" : spot.session ?? "last";
88
- return `${word} ${formatSpotTime(spot.observedAt)}`;
86
+ type SpotStampInput = Pick<OptionModelSpot, "basis" | "session" | "observedAt">;
87
+
88
+ /**
89
+ * The stamp every options pane puts beside the spot it priced a chain with
90
+ * (docs/research-data.md#greeks-spot-and-time): `close Tue 20 Oct` for the
91
+ * official close of the session the chain was quoted in, `pre-market Wed 21
92
+ * Oct 09:35 UTC` (`after hours`, `overnight`) for an extended-session print.
93
+ * Null for the regular session's latest price, which the pane's live or
94
+ * delayed status already dates.
95
+ */
96
+ export function modelSpotStamp(spot: SpotStampInput): string | null {
97
+ const dated = Number.isFinite(spot.observedAt);
98
+ if (spot.basis === "close") return dated ? `close ${formatCalendarDay(newYorkDate.format(spot.observedAt))}` : "close";
99
+ return spot.session && dated ? `${spot.session} ${formatSpotTime(spot.observedAt)}` : null;
100
+ }
101
+
102
+ /**
103
+ * The stamp's shorter forms, longest first, for a figure with less room
104
+ * (`StatItem.detailVariants`): `pre-market 09:35 UTC`, then `pre-market`;
105
+ * `close`. Empty when there is no stamp.
106
+ */
107
+ export function modelSpotStampVariants(spot: SpotStampInput): string[] {
108
+ if (!modelSpotStamp(spot)) return [];
109
+ if (spot.basis === "close") return ["close"];
110
+ return [`${spot.session} ${new Date(spot.observedAt).toISOString().slice(11, 16)} UTC`, spot.session!];
111
+ }
112
+
113
+ /** The stamp where a report or footer always dates the spot: `last Wed 21 Oct 14:05 UTC` for the regular session's price. */
114
+ export function modelSpotLabel(spot: SpotStampInput): string {
115
+ return modelSpotStamp(spot) ?? `last ${formatSpotTime(spot.observedAt)}`;
116
+ }
117
+
118
+ const HOUR_MS = 60 * 60_000;
119
+
120
+ /**
121
+ * A spot known only by its price and when it was observed, such as the one
122
+ * Gloom Cloud prices dealer gamma at: the session that time falls in on the
123
+ * listing's calendar. Pre-market from 04:00 New York to the open, after hours
124
+ * for four hours from the close, overnight between; a day the calendar does
125
+ * not know reads as the latest price.
126
+ */
127
+ export function observedModelSpot(price: number, observedAt: number, exchange?: string): OptionModelSpot {
128
+ const day = Number.isFinite(observedAt) ? getPublishedUsEquitySession(exchange || "NYSE", newYorkDate.format(observedAt)) : null;
129
+ const session: OptionModelSpot["session"] = day?.kind !== "session" ? null
130
+ : observedAt < day.open ? (observedAt >= day.open - 5.5 * HOUR_MS ? "pre-market" : "overnight")
131
+ : observedAt < day.close ? null
132
+ : observedAt < day.close + 4 * HOUR_MS ? "after hours" : "overnight";
133
+ return { price, observedAt, valuationTime: observedAt, basis: "last", session };
134
+ }
135
+
136
+ const EXTENDED_SESSION_WORDS: Record<ExtendedSession, NonNullable<OptionModelSpot["session"]>> = {
137
+ PRE: "pre-market", POST: "after hours", OVERNIGHT: "overnight",
138
+ };
139
+
140
+ /**
141
+ * The underlying's extended-session print beside a spot priced at the close,
142
+ * named by its session as the stamp names it: `pre-market 770.65`, `after
143
+ * hours mid 771.10`. Null when the spot is not the close, nothing traded
144
+ * outside the session, or the print is the spot.
145
+ */
146
+ export function modelSpotExtendedPrint(
147
+ quote: Quote | null | undefined,
148
+ spot: Pick<OptionModelSpot, "basis" | "price">,
149
+ format: (price: number) => string = formatModelSpotPrice,
150
+ ): string | null {
151
+ if (spot.basis !== "close") return null;
152
+ const print = getExtendedSessionDisplay(quote);
153
+ if (!print || !positive(print.price) || format(print.price) === format(spot.price)) return null;
154
+ return `${EXTENDED_SESSION_WORDS[print.session]}${print.mark ? " mid" : ""} ${format(print.price)}`;
89
155
  }
90
156
 
91
157
  /** A price to the cent, or to the tick it was quoted at below a dollar. */
@@ -5,6 +5,7 @@ import { colors, priceColor } from "../../../theme/colors";
5
5
  import { isPlainKey } from "../../../utils/keyboard";
6
6
  import { formatCompact, formatPercentRaw } from "../../../utils/format";
7
7
  import { formatMarketPrice, formatSignedMarketPrice, liveQuoteFormatOptions } from "../../../market-data/market/format";
8
+ import { getRegularSessionDisplay } from "../../../market-data/market/status";
8
9
  import { chainOptionContract, formatExpDate, optionContractName, resolveOptionsTarget } from "../../../utils/options";
9
10
  import {
10
11
  expiryIsoDate, findListedExpiry, formatDaysToExpiry, daysToExpiry, missingExpiryMessage, missingExpiryText, readOptionExpiration,
@@ -76,7 +77,10 @@ import { useIvRank } from "../iv-history/rank";
76
77
  import { useOptionsSessionOpen, useThrottledValue } from "../shared/volatility/live-session";
77
78
  import { useRemoteUiNode } from "../../../remote/semantic-tree";
78
79
  import { optionCarry, optionCostOfSpot } from "./carry";
79
- import { formatModelSpotPrice, modelSpotLabel, optionModelSpot } from "./model-spot";
80
+ import {
81
+ formatModelSpotPrice, modelSpotExtendedPrint, modelSpotLabel, modelSpotStamp, modelSpotStampVariants, optionModelSpot,
82
+ type OptionModelSpot,
83
+ } from "./model-spot";
80
84
  import { chainOpenInterestAsOf, loadListedOpenInterest, openInterestAsOfLabel, statedOpenInterestAsOf } from "./open-interest-as-of";
81
85
  import { useAsyncResource } from "../../../react/async-resource";
82
86
  import {
@@ -113,13 +117,35 @@ function transientTicker(symbol: string, exchange: string, currency: string): Ti
113
117
  }
114
118
 
115
119
  /**
116
- * The underlying's price and day change: the level every strike is read
117
- * against. A stale quote stays on screen, muted, while the footer says why.
120
+ * The underlying's price: the level every strike is read against. A chain
121
+ * priced at a session's close or an extended-session print shows that spot
122
+ * with its stamp (`modelSpotStamp`), and any extended print after it named by
123
+ * its session; the regular session's live price shows its day change. A stale
124
+ * quote stays on screen, muted, while the footer says why.
118
125
  */
119
- function spotItem(underlying: TickerFinancials | null | undefined, assetCategory: string | undefined): StatItem {
126
+ function spotItem(
127
+ underlying: TickerFinancials | null | undefined,
128
+ assetCategory: string | undefined,
129
+ modelSpot: OptionModelSpot | null,
130
+ ): StatItem {
120
131
  const quote = underlying?.quote;
121
132
  if (!quote || !Number.isFinite(quote.price)) return { id: "spot", label: "Spot", value: "--" };
122
133
  const options = liveQuoteFormatOptions(quote, quote.currency, assetCategory, underlying?.quoteMetadata?.instrumentType);
134
+ const stamp = modelSpot ? modelSpotStamp(modelSpot) : null;
135
+ if (modelSpot && stamp) {
136
+ const move = modelSpot.basis === "close" ? getRegularSessionDisplay(quote)?.change : quote.change;
137
+ const extended = modelSpotExtendedPrint(quote, modelSpot, (price) => formatMarketPrice(price, options));
138
+ // The band keeps its columns: the extended print, then the stamp's day, give way first.
139
+ const shorter = modelSpotStampVariants(modelSpot);
140
+ return {
141
+ id: "spot",
142
+ label: "Spot",
143
+ value: formatMarketPrice(modelSpot.price, options),
144
+ detail: extended ? `${stamp} · ${extended}` : stamp,
145
+ detailVariants: extended ? [stamp, ...shorter] : shorter,
146
+ color: quote.stale ? colors.textDim : priceColor(Number.isFinite(move) ? move! : 0),
147
+ };
148
+ }
123
149
  const change = Number.isFinite(quote.change) ? quote.change : 0;
124
150
  return {
125
151
  id: "spot",
@@ -552,10 +578,10 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
552
578
  putGreeks: volatilities ? calculateOptionGreeks(put, "put", greeksSpot, dividendYield, volatilities) : undefined,
553
579
  callExtrinsicPerYear: call && volatilities ? optionCarry(call, "call", greeksSpot, volatilities.valuationTime)?.extrinsicPerYear ?? null : null,
554
580
  putExtrinsicPerYear: put && volatilities ? optionCarry(put, "put", greeksSpot, volatilities.valuationTime)?.extrinsicPerYear ?? null : null,
555
- putCostOfSpot: put ? optionCostOfSpot(put, spot) : null,
581
+ putCostOfSpot: put ? optionCostOfSpot(put, greeksSpot) : null,
556
582
  isPositionStrike: !!parsed && strike === parsed.strike,
557
583
  };
558
- }), [callsByStrike, dividendYield, greeksSpot, parsed, putsByStrike, spot, strikes, volatilities]);
584
+ }), [callsByStrike, dividendYield, greeksSpot, parsed, putsByStrike, strikes, volatilities]);
559
585
  // The open interest's session: the chain's own, or for a chain that does not say, OPX's listing of the
560
586
  // same expiry, stated only when the counts are OPX's.
561
587
  const statedOpenInterest = useMemo(() => strikeChain ? statedOpenInterestAsOf(strikeChain) : undefined, [strikeChain]);
@@ -584,10 +610,12 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
584
610
  } : null);
585
611
  // What IV and the Greeks are valued at, and the open interest's date. In a
586
612
  // crowded footer it shortens, then gives way to the contract under the cursor.
587
- const basisText = [modelSpot ? `spot ${formatModelSpotPrice(modelSpot.price)} ${modelSpotLabel(modelSpot)}` : null,
613
+ // A stamped spot (a close, an extended print) is already the Spot figure's.
614
+ const footerSpot = modelSpot && !modelSpotStamp(modelSpot) ? modelSpot : null;
615
+ const basisText = [footerSpot ? `spot ${formatModelSpotPrice(footerSpot.price)} ${modelSpotLabel(footerSpot)}` : null,
588
616
  strikeChain && !listedOpenInterest.loading ? openInterestAsOfLabel(openInterestAsOf) : null].filter(Boolean).join(" · ");
589
- const shortBasisText = [modelSpot ? `${formatModelSpotPrice(modelSpot.price)} ${modelSpot.basis === "close" ? "close" : modelSpot.session ?? "last"} `
590
- + `${new Date(modelSpot.observedAt).toISOString().slice(11, 16)} UTC` : null,
617
+ const shortBasisText = [footerSpot ? `${formatModelSpotPrice(footerSpot.price)} last `
618
+ + `${new Date(footerSpot.observedAt).toISOString().slice(11, 16)} UTC` : null,
591
619
  strikeChain && !listedOpenInterest.loading ? openInterestAsOf?.date ? `OI ${openInterestAsOf.date.slice(5)}` : "OI undated" : null]
592
620
  .filter(Boolean).join(" · ");
593
621
  usePaneFooter("options-basis", () => ({ info: basisText ? [{ id: "greeks-basis",
@@ -597,9 +625,9 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
597
625
  // busy stream reads as a steady strip rather than flicker.
598
626
  // A new expiry, a refreshed snapshot or the underlying price arriving or
599
627
  // going stale shows at once; only the stream itself is paced.
600
- const summaryResetKey = useMemo(() => ({}), [strikeSelectionKey, strikeChain, spot == null]);
628
+ const summaryResetKey = useMemo(() => ({}), [strikeSelectionKey, strikeChain, greeksSpot == null]);
601
629
  const summaryInput = useThrottledValue(
602
- useMemo(() => ({ chain: liveChain, spot, volatilities }), [liveChain, spot, volatilities]),
630
+ useMemo(() => ({ chain: liveChain, spot: greeksSpot, volatilities }), [liveChain, greeksSpot, volatilities]),
603
631
  OPTIONS_SUMMARY_THROTTLE_MS,
604
632
  summaryResetKey,
605
633
  );
@@ -613,7 +641,7 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
613
641
  instrument: baseRequest?.instrument ?? null, expiration: selectedExpiration,
614
642
  selectedEntry: strikeChain === expirationChain ? expirationChainEntry
615
643
  : strikeChain === initialChain ? initialChainEntry : null,
616
- catalogue: availableExpirations, spot, spotAsOf: underlying?.quote?.lastUpdated,
644
+ catalogue: availableExpirations, spot: greeksSpot, spotAsOf: modelSpot?.observedAt,
617
645
  liveChain: liveChain !== strikeChain ? liveChain : null,
618
646
  });
619
647
  const enrichment = expirationUnavailable ? null : enrichmentState.snapshot;
@@ -901,7 +929,7 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
901
929
  // The root insets the terminal body, so the band gets the width inside it.
902
930
  const statWidth = Math.max(1, width - (nativePaneChrome ? 0 : 2));
903
931
  const figures = optionsSummaryItems({
904
- spot: spotItem(underlying, isOpt ? undefined : ticker.metadata.assetCategory),
932
+ spot: spotItem(underlying, isOpt ? undefined : ticker.metadata.assetCategory, modelSpot),
905
933
  summary, enrichment, currency: underlying?.quote?.currency || ticker.metadata.currency || "",
906
934
  ivRank: showIvRank ? { stats: ivRank } : null,
907
935
  });