gloomberb 0.17.0 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/package.json +1 -1
  2. package/src/{plugins/builtin/cloud → account}/auth-dialog.tsx +7 -7
  3. package/src/api-client/data.ts +5 -1
  4. package/src/api-client/doe.ts +19 -0
  5. package/src/api-client/earnings.ts +4 -2
  6. package/src/api-client/index.ts +1 -0
  7. package/src/app/global-shortcuts.ts +10 -0
  8. package/src/app/keybindings/actions.ts +16 -0
  9. package/src/app/runtime/desktop-deeplink.ts +1 -1
  10. package/src/app/runtime/update.ts +16 -0
  11. package/src/app.tsx +1 -1
  12. package/src/architecture/core-builtin-allowlist.json +4 -9
  13. package/src/architecture/dom-cell-metrics-allowlist.json +100 -26
  14. package/src/cli/commands/market.ts +87 -17
  15. package/src/cli/commands/options-chain.ts +93 -23
  16. package/src/cli/commands/options-leaps.ts +462 -63
  17. package/src/cli/commands/search.ts +10 -7
  18. package/src/cli/commands/ticker.ts +19 -10
  19. package/src/cli/glossary.ts +1 -1
  20. package/src/cli/index.ts +14 -6
  21. package/src/cli/known-gaps.ts +22 -0
  22. package/src/cli/pane-functions/catalog.ts +45 -10
  23. package/src/cli/pane-functions/dom.ts +36 -10
  24. package/src/cli/pane-functions/function-help.ts +4 -2
  25. package/src/cli/pane-functions/headless.ts +12 -1
  26. package/src/cli/pane-functions/index.ts +44 -3
  27. package/src/cli/pane-functions/lock-note.ts +56 -0
  28. package/src/cli/pane-functions/report-notices.ts +15 -0
  29. package/src/cli/symbol-args.ts +19 -0
  30. package/src/components/command-bar/list/view.tsx +2 -1
  31. package/src/components/command-bar/workflow/builtin.ts +2 -2
  32. package/src/components/command-bar/workflow/collection-actions.ts +1 -1
  33. package/src/components/feedback-dialog.tsx +1 -1
  34. package/src/components/index.ts +3 -2
  35. package/src/components/layout/header.tsx +58 -14
  36. package/src/components/layout/shell/chrome.ts +10 -4
  37. package/src/components/layout/shell/index.tsx +1 -1
  38. package/src/components/layout/status-bar-groups.ts +1 -1
  39. package/src/components/layout/status-bar.tsx +2 -2
  40. package/src/components/markdown-text.tsx +14 -2
  41. package/src/components/reader/article-reader.tsx +1 -1
  42. package/src/components/reader/detail-reader.tsx +10 -7
  43. package/src/components/reader/reader-text.tsx +32 -17
  44. package/src/components/ui/column-grid.tsx +206 -0
  45. package/src/components/ui/display.tsx +40 -3
  46. package/src/components/ui/fill.ts +8 -0
  47. package/src/components/ui/index.ts +2 -0
  48. package/src/layout-marketplace/gallery.tsx +2 -2
  49. package/src/layout-marketplace/linked-sync.ts +1 -1
  50. package/src/market-data/market/freshness.ts +3 -0
  51. package/src/market-data/published-apac-sessions.ts +12 -2
  52. package/src/market-data/quotes/screener-live-quotes.ts +31 -7
  53. package/src/plugins/builtin/account-management/form-components.tsx +4 -7
  54. package/src/plugins/builtin/account-management/password-dialog.tsx +3 -2
  55. package/src/plugins/builtin/backtest/pane.tsx +9 -3
  56. package/src/plugins/builtin/buildout/detail/company.tsx +4 -3
  57. package/src/plugins/builtin/buildout/detail/ui.tsx +9 -12
  58. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  59. package/src/plugins/builtin/chart-composer/presets.ts +33 -1
  60. package/src/plugins/builtin/chat/channel-labels.ts +2 -2
  61. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
  62. package/src/plugins/builtin/chat/content/index.tsx +2 -2
  63. package/src/plugins/builtin/chat/conversations.ts +1 -1
  64. package/src/plugins/builtin/chat/index.tsx +2 -2
  65. package/src/plugins/builtin/chat/share/flow.tsx +1 -1
  66. package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
  67. package/src/plugins/builtin/chat/sidebar.tsx +2 -2
  68. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
  69. package/src/plugins/builtin/cloud/askg/pane.tsx +2 -1
  70. package/src/plugins/builtin/cloud/askg/tool-timeline.tsx +3 -2
  71. package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
  72. package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
  73. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
  74. package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
  75. package/src/plugins/builtin/cloud/team/capability.ts +2 -2
  76. package/src/plugins/builtin/cloud/team/command.ts +4 -4
  77. package/src/plugins/builtin/cloud/team/module.tsx +3 -3
  78. package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
  79. package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
  80. package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
  81. package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
  82. package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
  83. package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
  84. package/src/plugins/builtin/cloud/team/views.ts +2 -2
  85. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
  86. package/src/plugins/builtin/cloud/thesis/detail.tsx +12 -10
  87. package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
  88. package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
  89. package/src/plugins/builtin/congress-trades/detail.tsx +8 -4
  90. package/src/plugins/builtin/connections/pane.tsx +16 -5
  91. package/src/plugins/builtin/cpi/pane.tsx +5 -4
  92. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  93. package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
  94. package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
  95. package/src/plugins/builtin/docs/blocks.tsx +13 -2
  96. package/src/plugins/builtin/docs/pane.tsx +6 -5
  97. package/src/plugins/builtin/doe/index.tsx +11 -4
  98. package/src/plugins/builtin/doe/pane.tsx +5 -4
  99. package/src/plugins/builtin/doe/prices.ts +84 -0
  100. package/src/plugins/builtin/doe/series.ts +4 -1
  101. package/src/plugins/builtin/earnings/board-model.ts +18 -1
  102. package/src/plugins/builtin/earnings/board-table.ts +18 -3
  103. package/src/plugins/builtin/earnings/headless.ts +13 -2
  104. package/src/plugins/builtin/earnings/history-model.ts +1 -1
  105. package/src/plugins/builtin/earnings/history-table.ts +2 -2
  106. package/src/plugins/builtin/earnings-calls/headless.ts +7 -21
  107. package/src/plugins/builtin/earnings-calls/index.tsx +2 -0
  108. package/src/plugins/builtin/earnings-calls/lock-note.ts +17 -0
  109. package/src/plugins/builtin/equity-screener/headless.ts +59 -1
  110. package/src/plugins/builtin/equity-screener/live.ts +82 -31
  111. package/src/plugins/builtin/equity-screener/pane.tsx +60 -23
  112. package/src/plugins/builtin/estimate-revisions/client.ts +1 -1
  113. package/src/plugins/builtin/estimate-revisions/headless.ts +9 -2
  114. package/src/plugins/builtin/estimate-revisions/index.tsx +3 -0
  115. package/src/plugins/builtin/estimate-revisions/lock-note.ts +23 -0
  116. package/src/plugins/builtin/executives/index.tsx +18 -0
  117. package/src/plugins/builtin/exposure/charts.tsx +4 -3
  118. package/src/plugins/builtin/filing-events/index.tsx +8 -10
  119. package/src/plugins/builtin/fixed-income/pane.tsx +2 -1
  120. package/src/plugins/builtin/gpu/equities.tsx +9 -6
  121. package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
  122. package/src/plugins/builtin/iv-history/format.ts +3 -0
  123. package/src/plugins/builtin/iv-history/headless.ts +3 -1
  124. package/src/plugins/builtin/iv-history/index.tsx +3 -1
  125. package/src/plugins/builtin/iv-history/model.ts +3 -1
  126. package/src/plugins/builtin/iv-history/screen-pane.tsx +3 -1
  127. package/src/plugins/builtin/iv-history/universe.ts +1 -1
  128. package/src/plugins/builtin/jobs/headless.ts +6 -20
  129. package/src/plugins/builtin/jobs/index.tsx +2 -0
  130. package/src/plugins/builtin/jobs/lock-note.ts +16 -0
  131. package/src/plugins/builtin/notes/index.tsx +1 -1
  132. package/src/plugins/builtin/notes/owner.tsx +2 -2
  133. package/src/plugins/builtin/options/carry.ts +36 -0
  134. package/src/plugins/builtin/options/index.tsx +2 -1
  135. package/src/plugins/builtin/options/model-spot.ts +94 -0
  136. package/src/plugins/builtin/options/open-interest-as-of.ts +74 -0
  137. package/src/plugins/builtin/options/view.tsx +69 -11
  138. package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
  139. package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
  140. package/src/plugins/builtin/options-scenario/client.ts +33 -9
  141. package/src/plugins/builtin/options-scenario/csp.ts +168 -0
  142. package/src/plugins/builtin/options-scenario/evidence.ts +3 -0
  143. package/src/plugins/builtin/options-scenario/headless.ts +24 -8
  144. package/src/plugins/builtin/options-scenario/index.tsx +3 -1
  145. package/src/plugins/builtin/options-scenario/pane.tsx +32 -6
  146. package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
  147. package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
  148. package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
  149. package/src/plugins/builtin/research/event-model.ts +7 -4
  150. package/src/plugins/builtin/risk-factors/index.tsx +18 -0
  151. package/src/plugins/builtin/risk-factors/pane.tsx +2 -1
  152. package/src/plugins/builtin/scanner/headless.ts +9 -2
  153. package/src/plugins/builtin/scanner/index.ts +3 -0
  154. package/src/plugins/builtin/scanner/lock-note.ts +17 -0
  155. package/src/plugins/builtin/series-inventory.ts +2 -1
  156. package/src/plugins/builtin/shared/cloud-denied.ts +39 -8
  157. package/src/plugins/builtin/shared/earnings-report.ts +10 -4
  158. package/src/plugins/builtin/shared/pro-lock-note.ts +58 -0
  159. package/src/plugins/builtin/shared/series-list-detail.tsx +10 -5
  160. package/src/plugins/builtin/social-mentions/client.ts +3 -3
  161. package/src/plugins/builtin/social-mentions/headless.ts +6 -2
  162. package/src/plugins/builtin/social-mentions/index.tsx +2 -1
  163. package/src/plugins/builtin/social-mentions/lock-note.ts +15 -0
  164. package/src/plugins/builtin/supply-chain/flow.tsx +15 -13
  165. package/src/plugins/builtin/supply-chain/graph.tsx +6 -4
  166. package/src/plugins/builtin/supply-chain/pane.tsx +8 -5
  167. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +14 -1
  168. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +9 -7
  169. package/src/plugins/builtin/ticker-detail/headless.ts +4 -1
  170. package/src/plugins/builtin/ticker-detail/overview/components.tsx +105 -104
  171. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -2
  172. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +34 -8
  173. package/src/plugins/builtin/vol-surface/pane.tsx +6 -3
  174. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  175. package/src/renderers/dom/data-table/index.tsx +48 -15
  176. package/src/renderers/dom/data-table/row.tsx +1 -1
  177. package/src/renderers/dom/desktop/popover.tsx +6 -15
  178. package/src/renderers/dom/host/input.tsx +27 -3
  179. package/src/renderers/dom/host/surface-3d.tsx +6 -4
  180. package/src/renderers/dom/styles.css +26 -6
  181. package/src/renderers/electrobun/view/app-services.ts +2 -22
  182. package/src/renderers/electrobun/view/news-source.ts +42 -0
  183. package/src/series/validate.ts +5 -0
  184. package/src/shares/dialog.tsx +2 -1
  185. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  186. package/src/sources/provider-router/financials.ts +5 -0
  187. package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
  188. package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
  189. package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
  190. package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
  191. package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
  192. package/src/types/data-provider.ts +3 -0
  193. package/src/types/financials.ts +28 -0
  194. package/src/types/plugin.ts +17 -4
  195. package/src/utils/decimal-comma.ts +18 -0
  196. package/src/utils/exchanges.ts +7 -0
  197. package/src/utils/financial-statements.ts +25 -0
  198. package/src/utils/forward-dividend.ts +39 -9
  199. package/src/utils/fundamentals.ts +41 -1
  200. package/src/utils/option-expiry.ts +16 -0
  201. package/src/utils/sec.ts +2 -1
@@ -6,7 +6,9 @@ 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
8
  import { formatExpDate, resolveOptionsTarget } from "../../../utils/options";
9
- import { expiryIsoDate, findListedExpiry, formatDaysToExpiry, daysToExpiry, missingExpiryText, readOptionExpiration } from "../../../utils/option-expiry";
9
+ import {
10
+ expiryIsoDate, findListedExpiry, formatDaysToExpiry, daysToExpiry, missingExpiryMessage, missingExpiryText, readOptionExpiration,
11
+ } from "../../../utils/option-expiry";
10
12
  import { canonicalTickerKey } from "../../../utils/exchanges";
11
13
  import { isOptionsUnavailableError, listedOptionsAlternative, optionsUnavailableTitle } from "../../../market-data/options-alternatives";
12
14
  import { useChartQueries, useOptionsQuery, useResolvedEntryValue, useTickerFinancials } from "../../../market-data/hooks";
@@ -72,6 +74,9 @@ import { useIvRank } from "../iv-history/rank";
72
74
  import { useOptionsSessionOpen, useThrottledValue } from "../shared/volatility/live-session";
73
75
  import { useRemoteUiNode } from "../../../remote/semantic-tree";
74
76
  import { optionCarry, optionCostOfSpot } from "./carry";
77
+ import { formatModelSpotPrice, modelSpotLabel, optionModelSpot } from "./model-spot";
78
+ import { chainOpenInterestAsOf, loadListedOpenInterest, openInterestAsOfLabel, statedOpenInterestAsOf } from "./open-interest-as-of";
79
+ import { useAsyncResource } from "../../../react/async-resource";
75
80
  import {
76
81
  ALL_STRIKES,
77
82
  STRIKE_WINDOW_PRESETS,
@@ -374,9 +379,12 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
374
379
  () => resolveDefaultStrikeTarget(parsed?.strike, underlyingPrice),
375
380
  [strikeChain, parsed?.strike, underlyingPrice == null],
376
381
  );
377
- const windowSpot = useMemo(() => spot, [strikeChain, spot == null]);
382
+ // The band reads deltas at the spot and instant the chain's Greeks are valued at, so a strike in it prints a delta in it.
383
+ const windowSpot = useMemo(() => optionModelSpot(underlyingStale ? null : underlying?.quote, strikeChain, Date.now(), effectiveExchange),
384
+ [strikeChain, spot == null]);
378
385
  const windowDeltas = useMemo(
379
- () => strikeWindow.kind === "delta" && strikeChain ? chainDeltasByStrike(strikeChain, windowSpot, dividendYield) : null,
386
+ () => strikeWindow.kind === "delta" && strikeChain
387
+ ? chainDeltasByStrike(strikeChain, windowSpot?.price, dividendYield, windowSpot?.valuationTime) : null,
380
388
  [dividendYield, strikeChain, strikeWindow.kind, windowSpot],
381
389
  );
382
390
  const strikeWindowResult = useMemo(
@@ -396,7 +404,7 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
396
404
  const expiryAsOfMs = Number.isFinite(chainAsOfMs) ? chainAsOfMs : Date.now();
397
405
  const expiryDate = selectedExpiration == null ? "" : expiryIsoDate(selectedExpiration);
398
406
  const expiryDays = selectedExpiration == null ? "" : formatDaysToExpiry(selectedExpiration, expiryAsOfMs);
399
- useRemoteUiNode(expiryDate && chain ? {
407
+ useRemoteUiNode(expiryDate && chain && !expirationUnavailable ? {
400
408
  role: "report-notice",
401
409
  label: "Expiry",
402
410
  getMetadata: () => ({
@@ -405,6 +413,12 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
405
413
  value: { date: expiryDate, daysToExpiry: daysToExpiry(selectedExpiration!, expiryAsOfMs) },
406
414
  }),
407
415
  } : null);
416
+ // `fn OMON --expiration` on a date the chain cannot show fails as `options --expiration` does.
417
+ useRemoteUiNode(expirationUnavailable && selectedExpiration != null && effectiveTicker ? {
418
+ role: "report-failure",
419
+ label: "Expiry unavailable",
420
+ getMetadata: () => missingExpiryMessage(effectiveTicker, selectedExpiration, availableExpirations),
421
+ } : null);
408
422
  // A refresh that failed keeps the last chain, which is then stale.
409
423
  const asOfEntry = strikeChain == null ? null : strikeChain === expirationChain ? expirationChainEntry : initialChainEntry;
410
424
  const chainDelay = asOfChain?.delayMinutes ?? 0;
@@ -494,11 +508,18 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
494
508
  () => new Map(liveChain?.puts.map((p) => [p.strike, p]) ?? []),
495
509
  [liveChain],
496
510
  );
511
+ // IV and the Greeks are valued at one spot and instant, which the footer
512
+ // names: the close a chain from the close was quoted against, else the
513
+ // latest price (docs/research-data.md#greeks-spot-and-time).
514
+ const underlyingQuote = underlyingStale ? null : underlying?.quote;
515
+ const modelSpot = useMemo(() => liveChain ? optionModelSpot(underlyingQuote, liveChain, Date.now(), effectiveExchange) : null,
516
+ [liveChain, underlyingQuote, effectiveExchange]);
517
+ const greeksSpot = modelSpot?.price;
497
518
  // One solve per applied batch: a streamed contract's IV comes from its live
498
519
  // midpoint, the rest from the snapshot, all against the same forward.
499
520
  const volatilities = useMemo(
500
- () => liveChain ? solveChainVolatilities(liveChain, spot, dividendYield) : null,
501
- [dividendYield, spot, liveChain],
521
+ () => liveChain ? solveChainVolatilities(liveChain, greeksSpot, dividendYield, modelSpot?.valuationTime) : null,
522
+ [dividendYield, greeksSpot, modelSpot?.valuationTime, liveChain],
502
523
  );
503
524
  const rows = useMemo<OptionTableRow[]>(() => strikes.map((strike) => {
504
525
  const call = callsByStrike.get(strike);
@@ -508,14 +529,51 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
508
529
  call,
509
530
  put,
510
531
  impliedVolatility: volatilities?.byStrike.get(strike),
511
- callGreeks: volatilities ? calculateOptionGreeks(call, "call", spot, dividendYield, volatilities) : undefined,
512
- putGreeks: volatilities ? calculateOptionGreeks(put, "put", spot, dividendYield, volatilities) : undefined,
513
- callExtrinsicPerYear: call && volatilities ? optionCarry(call, "call", spot, volatilities.valuationTime)?.extrinsicPerYear ?? null : null,
514
- putExtrinsicPerYear: put && volatilities ? optionCarry(put, "put", spot, volatilities.valuationTime)?.extrinsicPerYear ?? null : null,
532
+ callGreeks: volatilities ? calculateOptionGreeks(call, "call", greeksSpot, dividendYield, volatilities) : undefined,
533
+ putGreeks: volatilities ? calculateOptionGreeks(put, "put", greeksSpot, dividendYield, volatilities) : undefined,
534
+ callExtrinsicPerYear: call && volatilities ? optionCarry(call, "call", greeksSpot, volatilities.valuationTime)?.extrinsicPerYear ?? null : null,
535
+ putExtrinsicPerYear: put && volatilities ? optionCarry(put, "put", greeksSpot, volatilities.valuationTime)?.extrinsicPerYear ?? null : null,
515
536
  putCostOfSpot: put ? optionCostOfSpot(put, spot) : null,
516
537
  isPositionStrike: !!parsed && strike === parsed.strike,
517
538
  };
518
- }), [callsByStrike, dividendYield, parsed, putsByStrike, spot, strikes, volatilities]);
539
+ }), [callsByStrike, dividendYield, greeksSpot, parsed, putsByStrike, spot, strikes, volatilities]);
540
+ // The open interest's session: the chain's own, or for a chain that does not say, OPX's listing of the
541
+ // same expiry, stated only when the counts are OPX's.
542
+ const statedOpenInterest = useMemo(() => strikeChain ? statedOpenInterestAsOf(strikeChain) : undefined, [strikeChain]);
543
+ const listedExpiry = selectedExpiration == null || !effectiveTicker || !strikeChain || statedOpenInterest
544
+ ? null : expiryIsoDate(selectedExpiration);
545
+ const listedLoader = useCallback(() => loadListedOpenInterest(effectiveTicker, listedExpiry!), [effectiveTicker, listedExpiry]);
546
+ const listedOpenInterest = useAsyncResource(listedExpiry ? listedLoader : null);
547
+ const openInterestAsOf = useMemo(() => statedOpenInterest
548
+ ?? (strikeChain && listedOpenInterest.data ? chainOpenInterestAsOf(strikeChain, listedOpenInterest.data) : null),
549
+ [statedOpenInterest, strikeChain, listedOpenInterest.data]);
550
+ // A report of the pane (`gloomberb fn OMON`) names them too.
551
+ useRemoteUiNode(modelSpot ? {
552
+ role: "report-notice",
553
+ label: "Greeks spot",
554
+ getMetadata: () => ({
555
+ text: `Greeks at spot ${formatModelSpotPrice(modelSpot.price)} (${modelSpotLabel(modelSpot)})`,
556
+ key: "greeksSpot",
557
+ value: { spot: modelSpot.price, basis: modelSpot.basis === "close" ? "close" : modelSpot.session ?? "last",
558
+ asOf: new Date(modelSpot.observedAt).toISOString(), valuationTime: new Date(modelSpot.valuationTime).toISOString() },
559
+ }),
560
+ } : null);
561
+ useRemoteUiNode(strikeChain && !listedOpenInterest.loading ? {
562
+ role: "report-notice",
563
+ label: "Open interest as of",
564
+ getMetadata: () => ({ text: openInterestAsOfLabel(openInterestAsOf), key: "openInterestAsOf", value: openInterestAsOf?.date ?? null }),
565
+ } : null);
566
+ // What IV and the Greeks are valued at, and the open interest's date. In a
567
+ // crowded footer it shortens, then gives way to the contract under the cursor.
568
+ const basisText = [modelSpot ? `spot ${formatModelSpotPrice(modelSpot.price)} ${modelSpotLabel(modelSpot)}` : null,
569
+ strikeChain && !listedOpenInterest.loading ? openInterestAsOfLabel(openInterestAsOf) : null].filter(Boolean).join(" · ");
570
+ const shortBasisText = [modelSpot ? `${formatModelSpotPrice(modelSpot.price)} ${modelSpot.basis === "close" ? "close" : modelSpot.session ?? "last"} `
571
+ + `${new Date(modelSpot.observedAt).toISOString().slice(11, 16)} UTC` : null,
572
+ strikeChain && !listedOpenInterest.loading ? openInterestAsOf?.date ? `OI ${openInterestAsOf.date.slice(5)}` : "OI undated" : null]
573
+ .filter(Boolean).join(" · ");
574
+ usePaneFooter("options-basis", () => ({ info: basisText ? [{ id: "greeks-basis",
575
+ parts: [{ text: basisText, tone: "muted" as const }],
576
+ variants: [[{ text: shortBasisText, tone: "muted" as const }], []] }] : [] }), [basisText, shortBasisText]);
519
577
  // Figures that summarise the whole expiry move at most once a second, so a
520
578
  // busy stream reads as a steady strip rather than flicker.
521
579
  // A new expiry, a refreshed snapshot or the underlying price arriving or
@@ -1,11 +1,12 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { FieldGrid, KeyValueRow, QueryBar, usePaneFooter, usePaneNoticeFooter, type GridField } from "../../../components";
3
+ import { CompositeChart } from "../../../components/chart/composite/composite-chart";
3
4
  import { useAsyncResource, useInputCapture } from "../../../public/react";
4
5
  import { useShortcut } from "../../../react/input";
5
6
  import { useAppSelector, usePaneInstance, usePaneStateValue } from "../../../state/app/context";
6
7
  import { colors } from "../../../theme/colors";
7
8
  import type { PaneProps } from "../../../types/plugin";
8
- import { Box, ScrollBox } from "../../../ui";
9
+ import { Box, ScrollBox, Text } from "../../../ui";
9
10
  import { formatNumber } from "../../../utils/format";
10
11
  import { isPlainKey } from "../../../utils/keyboard";
11
12
  import { OPTIONS_CALCULATOR_PANE_ID, describeDraftProblem, draftFromParams, reconcileOptionCalcDraft, updateOptionCalcDraft, type OptionCalcDraft } from "./model";
@@ -20,6 +21,8 @@ import { useThrottledValue } from "../shared/volatility/live-session";
20
21
  import { valueBinomialOption, solveBinomialImpliedVolatility, effectiveBinomialSteps } from "./binomial";
21
22
  import { draftFromCalculatorInputs, parseCashDividends } from "./inputs";
22
23
  import { loadCalculatorSurfaceVol } from "./surface";
24
+ import { loadContractPremiumHistory, premiumSeries } from "./premium-history";
25
+ import type { ResolvedSeries } from "../../../time-series/types";
23
26
  import { OptionQuoteContext, optionQuoteContextHeight } from "../options/quote-context";
24
27
  import { useCalculatorEvidence, CALCULATOR_IGNORED_DIVIDENDS_NOTICE, type CalculatorScreenshotSnapshot } from "./evidence";
25
28
 
@@ -89,6 +92,12 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
89
92
  // any edit is theirs and ends the link for that input.
90
93
  const liveStreaming = useLiveStreamingSetting();
91
94
  const reference = draft.marketReference;
95
+ const contractSymbol = screenshotSnapshot ? null : reference?.contractSymbol ?? null;
96
+ const loadPremium = useCallback(async () => {
97
+ if (!contractSymbol) return [];
98
+ return loadContractPremiumHistory(contractSymbol);
99
+ }, [contractSymbol]);
100
+ const premiumResource = useAsyncResource(contractSymbol ? loadPremium : null);
92
101
  const priceLinked = !screenshotSnapshot && !!draft.marketPriceSource && !!reference && !!draft.symbol;
93
102
  const spotLinked = priceLinked && draft.spot === seed.spot;
94
103
  // One stable subscription while the price is linked: dropping the
@@ -331,6 +340,15 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
331
340
  {linkedDraft.marketReference && <Box paddingX={1} height={referenceHeight} flexShrink={0}>
332
341
  <OptionQuoteContext reference={linkedDraft.marketReference} width={resultWidth} height={referenceHeight} snapshot={!liveInputs} scrollable={false} />
333
342
  </Box>}
343
+ {contractSymbol ? (
344
+ <ContractPremiumChart
345
+ series={premiumSeries(premiumResource.data ?? [], colors.positive)}
346
+ loading={premiumResource.loading && !premiumResource.data}
347
+ unavailable={premiumResource.error != null && !premiumResource.data}
348
+ width={resultWidth}
349
+ focused={focused}
350
+ />
351
+ ) : null}
334
352
 
335
353
  <Box flexDirection={pairMetrics ? "row" : "column"} paddingX={1}>
336
354
  <KeyValueRow
@@ -366,6 +384,43 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
366
384
  );
367
385
  }
368
386
 
387
+ const PREMIUM_CHART_ROWS = 8;
388
+
389
+ function ContractPremiumChart({
390
+ series,
391
+ loading,
392
+ unavailable,
393
+ width,
394
+ focused,
395
+ }: {
396
+ series: ResolvedSeries[];
397
+ loading: boolean;
398
+ unavailable: boolean;
399
+ width: number;
400
+ focused: boolean;
401
+ }) {
402
+ const empty = unavailable ? "Premium history unavailable." : series.length < 1 ? "No premium history." : undefined;
403
+ return (
404
+ <Box flexDirection="column" paddingX={1} height={PREMIUM_CHART_ROWS} flexShrink={0}>
405
+ {empty && !loading ? (
406
+ <Text fg={colors.textDim}>{empty}</Text>
407
+ ) : (
408
+ <CompositeChart
409
+ series={series}
410
+ panels={[{ id: "main" }]}
411
+ width={width}
412
+ height={PREMIUM_CHART_ROWS}
413
+ focused={focused}
414
+ navigable={false}
415
+ remoteKind="option-premium-history"
416
+ formatValue={(value) => formatNumber(value, 2)}
417
+ emptyMessage={loading ? "Loading..." : "No premium history."}
418
+ />
419
+ )}
420
+ </Box>
421
+ );
422
+ }
423
+
369
424
  function SurfaceNotices({ notices, focused }: { notices: string[]; focused: boolean }) {
370
425
  usePaneNoticeFooter({ registrationId: "ovme-surface-notices", notices, focused });
371
426
  return null;
@@ -0,0 +1,34 @@
1
+ import { apiClient } from "../../../api-client";
2
+ import { scalarPoint, staticSeries } from "../../../components/chart/static/series";
3
+ import type { ResolvedSeries } from "../../../time-series/types";
4
+ import { OPTIONS_QUOTE_EXCHANGE } from "../options/live-quotes";
5
+
6
+ /** Daily closes for one listed contract. A typed premium has no contract to chart. */
7
+ export async function loadContractPremiumHistory(
8
+ contractSymbol: string,
9
+ signal?: AbortSignal,
10
+ ): Promise<{ date: string; close: number }[]> {
11
+ const response = await apiClient.getCloudHistory(contractSymbol, OPTIONS_QUOTE_EXCHANGE, {
12
+ interval: "1day",
13
+ outputsize: 400,
14
+ }, signal ? { signal } : {});
15
+ const rows = response.data ?? [];
16
+ return rows.flatMap((row) => {
17
+ const close = row.close;
18
+ if (!row.date || !Number.isFinite(close) || close < 0) return [];
19
+ const time = Date.parse(row.date);
20
+ if (!Number.isFinite(time)) return [];
21
+ return [{ date: row.date, close }];
22
+ });
23
+ }
24
+
25
+ export function premiumSeries(
26
+ rows: readonly { date: string; close: number }[],
27
+ color: string,
28
+ ): ResolvedSeries[] {
29
+ if (rows.length < 2) return [];
30
+ return [staticSeries(
31
+ rows.map((row) => scalarPoint(new Date(row.date), row.close)),
32
+ { id: "premium", label: "Premium", color, calendarSpaced: true },
33
+ )];
34
+ }
@@ -12,6 +12,7 @@ import { daysToExpiryFrom, extractImpliedForward, optionMid, solveImpliedVolatil
12
12
  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 { abortable, abortError } from "../../../utils/async-deadline";
15
+ import { extendedSession } from "../options/model-spot";
15
16
  import { errorMessage } from "../../../utils/errors";
16
17
 
17
18
  export interface ScenarioMarketSnapshot {
@@ -153,6 +154,26 @@ export async function loadScenarioMarket(
153
154
 
154
155
  const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
155
156
 
157
+ const cents = (value: number) => value.toFixed(2);
158
+
159
+ /**
160
+ * What the scenario's spot is, in words, for the labels that name it: `typed`
161
+ * for a what-if, `implied by option quotes` for a seeded strategy whose quote
162
+ * mids imply another spot than the last print, else the last price and its
163
+ * session (`pre-market`, `after hours`), or `input` without a market.
164
+ */
165
+ export function scenarioSpotBasis(
166
+ settings: Record<string, unknown>,
167
+ position: Pick<ScenarioPosition, "spot" | "legs">,
168
+ market: Pick<ScenarioMarketSnapshot, "spot" | "underlyingQuote"> | null | undefined,
169
+ ): string {
170
+ if (supplied(settings.spot)) return "typed";
171
+ const marketSpot = market?.spot;
172
+ if (marketSpot == null) return "input";
173
+ if (position.legs.some((leg) => leg.volatilitySource === "mid") && cents(marketSpot) !== cents(position.spot)) return "implied by option quotes";
174
+ return (market?.underlyingQuote ? extendedSession(market.underlyingQuote) : null) ?? "last price";
175
+ }
176
+
156
177
  function supplied(value: unknown): boolean { return value != null && value !== ""; }
157
178
 
158
179
  function numericSetting(settings: Record<string, unknown>, key: string): number | undefined {
@@ -204,9 +225,9 @@ export function scenarioPositionFromSettings(
204
225
  let legs = legText.trim() ? parseLegs(legText) : [];
205
226
  const strike = numericSetting(settings, "strike");
206
227
  if (strike != null && !(strike > 0)) throw new Error("strike must be positive");
207
- if (strike != null && !supplied(settings.strategy)) throw new Error("strike picks the strike of --strategy; add --strategy put, vertical or straddle");
228
+ if (strike != null && !supplied(settings.strategy)) throw new Error("strike picks the strike of --strategy; add --strategy put, csp, vertical or straddle");
208
229
  if (!legs.length && supplied(settings.strategy)) {
209
- if (!isScenarioStrategy(settings.strategy)) throw new Error("strategy must be vertical, straddle or put");
230
+ if (!isScenarioStrategy(settings.strategy)) throw new Error("strategy must be vertical, straddle, put or csp");
210
231
  if (!market?.chain || market.warnings.includes("Options chain is stale")) {
211
232
  throw new Error(market?.missingExpiry ?? "A current options chain is required to seed a strategy");
212
233
  }
@@ -242,8 +263,10 @@ export function scenarioPositionFromSettings(
242
263
  return position;
243
264
  }
244
265
 
245
- type ScenarioStrategy = "vertical" | "straddle" | "put";
246
- const isScenarioStrategy = (value: unknown): value is ScenarioStrategy => value === "vertical" || value === "straddle" || value === "put";
266
+ /** The seeds `--strategy` builds: a call vertical, a straddle, one long put, or one short put (a cash-secured put). */
267
+ export const SCENARIO_STRATEGIES = ["vertical", "straddle", "put", "csp"] as const;
268
+ type ScenarioStrategy = typeof SCENARIO_STRATEGIES[number];
269
+ const isScenarioStrategy = (value: unknown): value is ScenarioStrategy => SCENARIO_STRATEGIES.includes(value as ScenarioStrategy);
247
270
 
248
271
  interface StrategyPricing { spot: number; asOf: number; rate: number }
249
272
 
@@ -267,8 +290,9 @@ interface StrategyParity { forward: number; daysToExpiry: number }
267
290
  * scenario at the spot it implies.
268
291
  *
269
292
  * Each strategy is anchored at the strike nearest spot, or at `strike` when
270
- * one is given: a long put (a protective put), a call vertical from the
271
- * anchor to the next listed strike up, or a straddle at the anchor.
293
+ * one is given: a long put (a protective put), a short put (a cash-secured
294
+ * put, sold at the midpoint like every seed), a call vertical from the anchor
295
+ * to the next listed strike up, or a straddle at the anchor.
272
296
  */
273
297
  function scenarioStrategyLegs(
274
298
  chain: OptionsChain, spot: number, strategy: ScenarioStrategy, pricing: StrategyPricing | null, strike?: number,
@@ -304,13 +328,13 @@ function scenarioStrategyLegs(
304
328
  if (strike == null) return new Error("The selected chain has no complete quoted strategy with usable IV");
305
329
  const listed = [...new Set(candidates.map(({ contract }) => contract.strike))]
306
330
  .sort((a, b) => Math.abs(a - strike) - Math.abs(b - strike)).slice(0, 4).sort((a, b) => a - b);
307
- return new Error(`The selected chain has no complete quoted ${strategy} at strike ${strike} with usable IV`
331
+ return new Error(`The selected chain has no complete quoted ${strategy === "csp" ? "put to sell" : strategy} at strike ${strike} with usable IV`
308
332
  + (listed.length ? `; nearest quoted strikes: ${listed.join(", ")}` : ""));
309
333
  };
310
- if (strategy === "put") {
334
+ if (strategy === "put" || strategy === "csp") {
311
335
  const put = puts.find(anchored);
312
336
  if (!put) throw missing(puts);
313
- return { legs: [{ id: put.contract.contractSymbol, side: "put", quantity: 1, strike: put.contract.strike,
337
+ return { legs: [{ id: put.contract.contractSymbol, side: "put", quantity: strategy === "csp" ? -1 : 1, strike: put.contract.strike,
314
338
  expiration: put.contract.expiration, price: put.mid, priceSource: "mid", volatility: put.volatility, multiplier: 100,
315
339
  ...(put.solved ? { volatilitySource: "mid" as const } : {}) }],
316
340
  parity: put.solved ? parityFor(put.contract.expiration) : null };
@@ -0,0 +1,168 @@
1
+ import type { StatItem } from "../../../components";
2
+ import type { DataProvider } from "../../../types/data-provider";
3
+ import type { HeadlessPaneEntry } from "../../../types/headless";
4
+ import { loadEarningsCalendar } from "../earnings/data/cache";
5
+ import { formatHedgePrice } from "./hedge";
6
+ import { UNKNOWN_CURRENCY, type ScenarioPosition } from "./model";
7
+
8
+ /**
9
+ * A cash-secured put (docs/research-data.md#cash-secured-put): one short put
10
+ * with the cash to buy the shares at the strike set aside. Figures, for the
11
+ * position as entered:
12
+ *
13
+ * - cash secured = strike x multiplier x contracts
14
+ * - premium received = entry price x multiplier x contracts; a `--strategy csp`
15
+ * seed enters at the quote midpoint
16
+ * - yield on cash = premium / cash secured
17
+ * - annualised yield = yield on cash x 365 / days to expiry (simple, not compounded)
18
+ * - out of the money = (spot - strike) / spot, against the scenario's spot
19
+ * - break-even = strike - entry price
20
+ * - days to expiry = calendar days from the valuation date in New York to the expiry date
21
+ * - earnings before expiry: yes when the next report is on or before the expiry date,
22
+ * no when it is after, unknown when the calendar has no upcoming date
23
+ */
24
+ export interface CashSecuredPut {
25
+ currency: string;
26
+ strike: number;
27
+ /** Expiry calendar date, YYYY-MM-DD. */
28
+ expiry: string;
29
+ contracts: number;
30
+ multiplier: number;
31
+ /** Per share: what the put is sold at. */
32
+ price: number;
33
+ /** "mid" when a strategy seed entered the put at its quote midpoint, else the entry price as typed. */
34
+ priceBasis: "mid" | "entry";
35
+ cashSecured: number;
36
+ premium: number;
37
+ /** Premium over cash secured, as a fraction. */
38
+ yieldOnCash: number;
39
+ /** Null on the expiry day, when there is no day to annualise over. */
40
+ annualizedYield: number | null;
41
+ daysToExpiry: number;
42
+ /** Valuation date in New York, YYYY-MM-DD. */
43
+ valuationDate: string;
44
+ spot: number;
45
+ /** What the spot is: `implied by option quotes`, `typed`, `last price`. */
46
+ spotBasis: string;
47
+ /** (spot - strike) / spot as a fraction; negative when the put is in the money. */
48
+ outOfTheMoney: number | null;
49
+ breakEven: number;
50
+ earnings: CspEarnings;
51
+ }
52
+
53
+ export interface CspEarnings {
54
+ /** The next report date, YYYY-MM-DD, or null when none is published. */
55
+ date: string | null;
56
+ /** Null is unknown: no upcoming date to compare. */
57
+ beforeExpiry: boolean | null;
58
+ }
59
+
60
+ const DAY_MS = 86_400_000;
61
+ const newYorkDate = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit" });
62
+
63
+ /** The calendar date of a moment in New York, YYYY-MM-DD. */
64
+ function newYorkCalendarDate(time: number): string {
65
+ return newYorkDate.format(time);
66
+ }
67
+
68
+ /** Whether the next report falls before the put expires; unknown without an upcoming date. */
69
+ export function earningsBeforeExpiry(earningsDate: string | null | undefined, valuationDate: string, expiry: string): CspEarnings {
70
+ if (!earningsDate || !/^\d{4}-\d{2}-\d{2}$/.test(earningsDate) || earningsDate < valuationDate) return { date: null, beforeExpiry: null };
71
+ return { date: earningsDate, beforeExpiry: earningsDate <= expiry };
72
+ }
73
+
74
+ /**
75
+ * The position as a cash-secured put, or null when it is not one short put.
76
+ * `spotBasis` names the spot the out-of-the-money distance is read against.
77
+ */
78
+ export function cashSecuredPut(
79
+ position: ScenarioPosition, spotBasis: string, earningsDate: string | null | undefined,
80
+ ): CashSecuredPut | null {
81
+ const [leg] = position.legs;
82
+ if (position.legs.length !== 1 || !leg || leg.side !== "put" || leg.quantity >= 0) return null;
83
+ const contracts = -leg.quantity;
84
+ const expiry = new Date(leg.expiration * 1000).toISOString().slice(0, 10);
85
+ const valuationDate = newYorkCalendarDate(position.asOf);
86
+ const daysToExpiry = Math.max(0, Math.round((Date.parse(`${expiry}T00:00:00Z`) - Date.parse(`${valuationDate}T00:00:00Z`)) / DAY_MS));
87
+ const cashSecured = leg.strike * leg.multiplier * contracts;
88
+ const premium = Math.round(leg.price * leg.multiplier * contracts * 1e6) / 1e6;
89
+ const yieldOnCash = premium / cashSecured;
90
+ return {
91
+ currency: position.currency, strike: leg.strike, expiry, contracts, multiplier: leg.multiplier,
92
+ price: leg.price, priceBasis: leg.priceSource === "mid" ? "mid" : "entry",
93
+ cashSecured, premium, yieldOnCash, annualizedYield: daysToExpiry > 0 ? yieldOnCash * 365 / daysToExpiry : null,
94
+ daysToExpiry, valuationDate, spot: position.spot, spotBasis,
95
+ outOfTheMoney: position.spot > 0 ? (position.spot - leg.strike) / position.spot : null,
96
+ breakEven: leg.strike - leg.price,
97
+ earnings: earningsBeforeExpiry(earningsDate, valuationDate, expiry),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * The next report date from the earnings calendar, YYYY-MM-DD; null when the
103
+ * calendar has none for the symbol or cannot be read.
104
+ */
105
+ export async function loadNextEarningsDate(provider: DataProvider | null | undefined, symbol: string): Promise<string | null> {
106
+ const key = symbol.trim().toUpperCase();
107
+ const result = await loadEarningsCalendar(provider, [key]).catch(() => null);
108
+ const event = result?.events.find((entry) => entry.symbol.trim().toUpperCase() === key);
109
+ const time = event?.earningsDate.getTime();
110
+ return time != null && Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : null;
111
+ }
112
+
113
+ const money = (value: number) => value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
114
+ const percent = (fraction: number, digits = 2) => `${(fraction * 100).toFixed(digits)}%`;
115
+ const unitOf = (csp: CashSecuredPut) => csp.currency && csp.currency !== UNKNOWN_CURRENCY ? ` ${csp.currency}` : "";
116
+ const strikeText = (value: number) => formatHedgePrice(value).replace(/\.00$/, "");
117
+
118
+ /** `5.5% below spot 89.98 (implied by option quotes)`, or above it for a put in the money. */
119
+ function otmText(csp: CashSecuredPut): string {
120
+ if (csp.outOfTheMoney == null) return "--";
121
+ const side = csp.outOfTheMoney >= 0 ? "below" : "above";
122
+ return `${percent(Math.abs(csp.outOfTheMoney), 1)} ${side} spot ${money(csp.spot)} (${csp.spotBasis})`;
123
+ }
124
+
125
+ function earningsText(earnings: CspEarnings): string {
126
+ if (earnings.beforeExpiry == null) return "unknown (no upcoming date published)";
127
+ return `${earnings.beforeExpiry ? "yes" : "no"} (next report ${earnings.date})`;
128
+ }
129
+
130
+ const priceWord = (csp: CashSecuredPut) => csp.priceBasis === "mid" ? "quote mid" : "entry price";
131
+
132
+ /** The report's Cash-secured put section; `value` keeps full precision, `formatted` is what text output prints. */
133
+ export function cspReportEntries(csp: CashSecuredPut): HeadlessPaneEntry[] {
134
+ const unit = unitOf(csp);
135
+ return [
136
+ { key: "cashSecured", label: "Cash secured", value: csp.cashSecured,
137
+ formatted: `${money(csp.cashSecured)}${unit} (${strikeText(csp.strike)} x ${csp.multiplier} x ${csp.contracts})` },
138
+ { key: "premium", label: "Premium received", value: csp.premium,
139
+ formatted: `${money(csp.premium)}${unit} (${formatHedgePrice(csp.price)} per share, ${priceWord(csp)})` },
140
+ { key: "yieldOnCash", label: "Yield on cash", value: csp.yieldOnCash,
141
+ formatted: `${percent(csp.yieldOnCash)} (${money(csp.premium)} / ${money(csp.cashSecured)})` },
142
+ { key: "annualizedYield", label: "Annualised yield", value: csp.annualizedYield,
143
+ formatted: csp.annualizedYield == null ? "-- (expires today)" : `${percent(csp.annualizedYield)} (yield on cash x 365 / ${csp.daysToExpiry})` },
144
+ { key: "outOfTheMoney", label: "Out of the money", value: csp.outOfTheMoney, formatted: otmText(csp) },
145
+ { key: "breakEven", label: "Break-even", value: csp.breakEven,
146
+ formatted: `${formatHedgePrice(csp.breakEven)} per share (${strikeText(csp.strike)} - ${formatHedgePrice(csp.price)})` },
147
+ { key: "daysToExpiry", label: "Days to expiry", value: csp.daysToExpiry,
148
+ formatted: `${csp.daysToExpiry} (${csp.valuationDate} to ${csp.expiry})` },
149
+ { key: "earningsBeforeExpiry", label: "Earnings before expiry", value: csp.earnings.beforeExpiry, formatted: earningsText(csp.earnings) },
150
+ ];
151
+ }
152
+
153
+ /** The pane's figures: the same numbers as the report, sized for a StatGrid. */
154
+ export function cspStatItems(csp: CashSecuredPut): StatItem[] {
155
+ const earnings = csp.earnings;
156
+ return [
157
+ { id: "csp-cash", label: "Cash secured", value: money(csp.cashSecured), detail: `${strikeText(csp.strike)} x ${csp.multiplier * csp.contracts}` },
158
+ { id: "csp-premium", label: "Premium", value: money(csp.premium), detail: `${priceWord(csp)} ${formatHedgePrice(csp.price)}` },
159
+ { id: "csp-yield", label: "Yield", value: percent(csp.yieldOnCash),
160
+ detail: csp.annualizedYield == null ? "expires today" : `${percent(csp.annualizedYield)} a year` },
161
+ { id: "csp-otm", label: "OTM", value: csp.outOfTheMoney == null ? "--" : percent(csp.outOfTheMoney, 1),
162
+ detail: `vs ${money(csp.spot)} ${csp.spotBasis === "implied by option quotes" ? "implied" : csp.spotBasis}` },
163
+ { id: "csp-break-even", label: "Break-even", value: formatHedgePrice(csp.breakEven), detail: "per share" },
164
+ { id: "csp-days", label: "DTE", value: String(csp.daysToExpiry), detail: csp.expiry },
165
+ { id: "csp-earnings", label: "Earnings", value: earnings.beforeExpiry == null ? "unknown" : earnings.beforeExpiry ? "before expiry" : "after expiry",
166
+ detail: earnings.date ?? "no date", tone: earnings.beforeExpiry ? "warning" : undefined },
167
+ ];
168
+ }
@@ -83,6 +83,7 @@ export const scenarioScreenshotEvidence: PaneScreenshotEvidenceHook<ScenarioEvid
83
83
  controls?: ScenarioControls | null;
84
84
  market?: unknown;
85
85
  inputSource?: string;
86
+ csp?: { earnings: { date: string | null } };
86
87
  } | undefined;
87
88
  const position = metadata?.position;
88
89
  const controls = metadata?.controls;
@@ -96,6 +97,8 @@ export const scenarioScreenshotEvidence: PaneScreenshotEvidenceHook<ScenarioEvid
96
97
  scenarioSnapshot: metadata?.scenario ?? null,
97
98
  scenarioMarketSnapshot: metadata?.market ?? null,
98
99
  scenarioSnapshotErrors: loaded.result.errors ?? [],
100
+ // A cash-secured put shows the earnings date the report read, not another lookup's.
101
+ ...(metadata?.csp ? { scenarioEarningsDate: metadata.csp.earnings.date } : {}),
99
102
  ...(position ? { seedPosition: position } : {}),
100
103
  ...(controls ? { date: new Date(controls.date).toISOString(), volShift: controls.volShift * 100,
101
104
  spotRange: controls.spotRange * 100 } : {}),
@@ -5,9 +5,11 @@ import {
5
5
  } from "../../../utils/option-expiry";
6
6
  import { resolveHeadlessInstrument } from "../shared/headless-market-data";
7
7
  import {
8
- createScenarioDependencies, loadScenarioMarket, scenarioControlsFromSettings, scenarioPositionFromSettings,
9
- type ScenarioMarketSnapshot,
8
+ createScenarioDependencies, loadScenarioMarket, SCENARIO_STRATEGIES, scenarioControlsFromSettings, scenarioPositionFromSettings,
9
+ scenarioSpotBasis, type ScenarioMarketSnapshot,
10
10
  } from "./client";
11
+ import { cashSecuredPut, cspReportEntries, loadNextEarningsDate } from "./csp";
12
+ import { formatSpotTime } from "../options/model-spot";
11
13
  import { buildScenario, currencyLabel, parseLegs, scenarioValueUnit } from "./model";
12
14
  import { hedgeInputsFromSettings, hedgeReportEntries, scenarioHedgeBudget } from "./hedge";
13
15
 
@@ -33,14 +35,17 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
33
35
  describe: (args) => `OSA ${args.symbols[0] ?? ""}`,
34
36
  description: "A typed or chain-seeded options position valued across spot, date and volatility: its legs, value and Greeks, expiry risk and a "
35
37
  + "scenario P&L grid. With --nav and --budget-bps, how many contracts a hedge budget buys, the premium spent, the notional protected and "
36
- + "coverage of an equity sleeve.",
38
+ + "coverage of an equity sleeve. --strategy csp sells a cash-secured put: cash secured, premium, yield on cash, annualised yield, "
39
+ + "distance out of the money, break-even, days to expiry and whether earnings fall before expiry.",
37
40
  discovery: { screenshotReadiness: "ready", limitations: ["European scenario values do not model early assignment or exercise."] },
38
41
  options: [
39
42
  { key: "tab", type: "enum", values: ["payoff", "grid", "legs"].map((value) => ({ value })), defaultValue: "payoff",
40
43
  description: "Initial view", pluginState: { pluginId: "ticker-research", key: "activeTabId" } },
41
44
  { key: "legs", type: "string", description: "Semicolon-separated side,strike,YYYY-MM-DD,signed quantity,entry price per share,IV percent[,multiplier]" },
42
- { key: "strategy", type: "enum", values: [{ value: "vertical" }, { value: "straddle" }, { value: "put" }],
43
- description: "Build a position from current two-sided chain quotes: a call vertical, a straddle, or one long put (a protective put)" },
45
+ { key: "strategy", type: "enum", values: SCENARIO_STRATEGIES.map((value) => ({ value })),
46
+ example: "--strategy csp --strike 85 --expiration 2026-11-20",
47
+ description: "Build a position from current two-sided chain quotes: a call vertical, a straddle, one long put (a protective put), "
48
+ + "or csp, one short put sold at its midpoint with the cash to buy the shares set aside (a cash-secured put)" },
44
49
  { key: "strike", type: "string", description: "The --strategy strike: the put, the vertical's long call or the straddle. Nearest the money when omitted" },
45
50
  { key: "expiration", type: "string", placeholder: OPTION_EXPIRATION_PLACEHOLDER, example: "--strategy vertical --expiration 2027-01-15",
46
51
  description: `Chain expiry for --strategy, as ${OPTION_EXPIRATION_FORMAT}`, normalize: (value) => expirationOptionSeconds(value) },
@@ -76,6 +81,8 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
76
81
  rateExpiration: typedExpiries.length ? Math.min(...typedExpiries) : undefined, signal: ctx.signal,
77
82
  }, createScenarioDependencies(ctx.marketData, ctx.apiClient));
78
83
  }
84
+ // A cash-secured put says whether earnings fall before it expires; the calendar is read only for one.
85
+ const earningsDate = settings.strategy === "csp" ? await loadNextEarningsDate(ctx.marketData, symbol) : null;
79
86
  ctx.signal.throwIfAborted();
80
87
  const position = scenarioPositionFromSettings(settings, market);
81
88
  const controls = position ? scenarioControlsFromSettings(settings, position) : null;
@@ -90,6 +97,10 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
90
97
  // A seeded strategy starts at the spot its quote mids imply; the last print is reported beside it.
91
98
  const lastPrint = midVolatility && !supplied(settings.spot) && market.spot != null && money(market.spot) !== money(position!.spot)
92
99
  ? market.spot : null;
100
+ const spotBasis = position ? scenarioSpotBasis(settings, position, market) : null;
101
+ const csp = position && settings.strategy === "csp" ? cashSecuredPut(position, spotBasis!, earningsDate) : null;
102
+ const lastSession = market.underlyingQuote && spotBasis === "implied by option quotes"
103
+ ? scenarioSpotBasis({}, { spot: market.spot!, legs: [] }, market) : null;
93
104
  const chain = market.chain;
94
105
  const currency = position ? currencyLabel(position.currency) : null;
95
106
  // Value, P&L, risk and the grid add every contract; entry prices and spot are per share.
@@ -111,13 +122,16 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
111
122
  ...(midVolatility ? { volatilityFrom: leg.volatilitySource === "mid" ? "quote mid" : "provider" } : {}) })) },
112
123
  { title: "Inputs", entries: [
113
124
  { key: "spot", label: lastPrint != null ? "Spot implied by option quotes" : "Spot", value: position!.spot,
114
- formatted: `${money(position!.spot)} ${PRICE_UNIT}` },
115
- ...(lastPrint != null ? [{ key: "last", label: "Last price", value: lastPrint, formatted: `${money(lastPrint)} ${PRICE_UNIT}` }] : []),
125
+ formatted: `${money(position!.spot)} ${PRICE_UNIT}${lastPrint == null && spotBasis !== "last price" ? ` (${spotBasis})` : ""}` },
126
+ ...(lastPrint != null ? [{ key: "last", label: "Last price", value: lastPrint,
127
+ formatted: `${money(lastPrint)} ${PRICE_UNIT}${lastSession && lastSession !== "last price" ? ` (${lastSession})` : ""}` }] : []),
128
+ { key: "asOf", label: "Valued at", value: new Date(position!.asOf).toISOString(), formatted: formatSpotTime(position!.asOf) },
116
129
  { key: "rate", label: "Rate", value: position!.rate, formatted: percent(position!.rate) },
117
130
  { key: "dividendYield", label: "Dividend yield", value: position!.dividendYield, formatted: percent(position!.dividendYield) },
118
131
  { key: "currency", label: "Currency", value: position!.currency, formatted: currency! },
119
132
  ] },
120
133
  ...(hedge ? [{ title: "Hedge budget", entries: hedgeReportEntries(hedge) }] : []),
134
+ ...(csp ? [{ title: "Cash-secured put", entries: cspReportEntries(csp) }] : []),
121
135
  { title: `Valuation (${per})`, entries: Object.entries(scenario.valuation).map(([key, value]) => ({ key,
122
136
  label: VALUATION_LABELS[key]?.(currency!) ?? key, value, formatted: money(value) })) },
123
137
  { title: `Expiry risk (${per})`, entries: [
@@ -136,7 +150,9 @@ export const optionsScenarioHeadless: HeadlessPaneDefinition<"bundle"> = {
136
150
  ] : [],
137
151
  complete: scenario != null && errors.length === 0,
138
152
  unavailableSymbols: scenario ? [] : [symbol], errors,
139
- metadata: { scenario, position, controls, market, ...(hedge ? { hedge } : {}), inputSource: suppliedInputs ? "user" : "market",
153
+ metadata: { scenario, position, controls, market, ...(hedge ? { hedge } : {}), ...(csp ? { csp } : {}),
154
+ ...(position ? { spot: { price: position.spot, basis: spotBasis, valuedAt: new Date(position.asOf).toISOString() } } : {}),
155
+ inputSource: suppliedInputs ? "user" : "market",
140
156
  unit: position?.currency || "currency unspecified",
141
157
  // Leg entry prices and spot are per share; value, P&L, risk, Greeks in currency and the grid are for valueUnit.
142
158
  ...(position ? { units: { currency: position.currency, price: PRICE_UNIT, value: valueUnit } } : {}),