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.
- package/package.json +1 -1
- package/src/{plugins/builtin/cloud → account}/auth-dialog.tsx +7 -7
- package/src/api-client/data.ts +5 -1
- package/src/api-client/doe.ts +19 -0
- package/src/api-client/earnings.ts +4 -2
- package/src/api-client/index.ts +1 -0
- package/src/app/global-shortcuts.ts +10 -0
- package/src/app/keybindings/actions.ts +16 -0
- package/src/app/runtime/desktop-deeplink.ts +1 -1
- package/src/app/runtime/update.ts +16 -0
- package/src/app.tsx +1 -1
- package/src/architecture/core-builtin-allowlist.json +4 -9
- package/src/architecture/dom-cell-metrics-allowlist.json +100 -26
- package/src/cli/commands/market.ts +87 -17
- package/src/cli/commands/options-chain.ts +93 -23
- package/src/cli/commands/options-leaps.ts +462 -63
- package/src/cli/commands/search.ts +10 -7
- package/src/cli/commands/ticker.ts +19 -10
- package/src/cli/glossary.ts +1 -1
- package/src/cli/index.ts +14 -6
- package/src/cli/known-gaps.ts +22 -0
- package/src/cli/pane-functions/catalog.ts +45 -10
- package/src/cli/pane-functions/dom.ts +36 -10
- package/src/cli/pane-functions/function-help.ts +4 -2
- package/src/cli/pane-functions/headless.ts +12 -1
- package/src/cli/pane-functions/index.ts +44 -3
- package/src/cli/pane-functions/lock-note.ts +56 -0
- package/src/cli/pane-functions/report-notices.ts +15 -0
- package/src/cli/symbol-args.ts +19 -0
- package/src/components/command-bar/list/view.tsx +2 -1
- package/src/components/command-bar/workflow/builtin.ts +2 -2
- package/src/components/command-bar/workflow/collection-actions.ts +1 -1
- package/src/components/feedback-dialog.tsx +1 -1
- package/src/components/index.ts +3 -2
- package/src/components/layout/header.tsx +58 -14
- package/src/components/layout/shell/chrome.ts +10 -4
- package/src/components/layout/shell/index.tsx +1 -1
- package/src/components/layout/status-bar-groups.ts +1 -1
- package/src/components/layout/status-bar.tsx +2 -2
- package/src/components/markdown-text.tsx +14 -2
- package/src/components/reader/article-reader.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +10 -7
- package/src/components/reader/reader-text.tsx +32 -17
- package/src/components/ui/column-grid.tsx +206 -0
- package/src/components/ui/display.tsx +40 -3
- package/src/components/ui/fill.ts +8 -0
- package/src/components/ui/index.ts +2 -0
- package/src/layout-marketplace/gallery.tsx +2 -2
- package/src/layout-marketplace/linked-sync.ts +1 -1
- package/src/market-data/market/freshness.ts +3 -0
- package/src/market-data/published-apac-sessions.ts +12 -2
- package/src/market-data/quotes/screener-live-quotes.ts +31 -7
- package/src/plugins/builtin/account-management/form-components.tsx +4 -7
- package/src/plugins/builtin/account-management/password-dialog.tsx +3 -2
- package/src/plugins/builtin/backtest/pane.tsx +9 -3
- package/src/plugins/builtin/buildout/detail/company.tsx +4 -3
- package/src/plugins/builtin/buildout/detail/ui.tsx +9 -12
- package/src/plugins/builtin/chart-composer/index.tsx +1 -1
- package/src/plugins/builtin/chart-composer/presets.ts +33 -1
- package/src/plugins/builtin/chat/channel-labels.ts +2 -2
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
- package/src/plugins/builtin/chat/content/index.tsx +2 -2
- package/src/plugins/builtin/chat/conversations.ts +1 -1
- package/src/plugins/builtin/chat/index.tsx +2 -2
- package/src/plugins/builtin/chat/share/flow.tsx +1 -1
- package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
- package/src/plugins/builtin/chat/sidebar.tsx +2 -2
- package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
- package/src/plugins/builtin/cloud/askg/pane.tsx +2 -1
- package/src/plugins/builtin/cloud/askg/tool-timeline.tsx +3 -2
- package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
- package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
- package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
- package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
- package/src/plugins/builtin/cloud/team/capability.ts +2 -2
- package/src/plugins/builtin/cloud/team/command.ts +4 -4
- package/src/plugins/builtin/cloud/team/module.tsx +3 -3
- package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
- package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
- package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
- package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
- package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
- package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
- package/src/plugins/builtin/cloud/team/views.ts +2 -2
- package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/detail.tsx +12 -10
- package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +8 -4
- package/src/plugins/builtin/connections/pane.tsx +16 -5
- package/src/plugins/builtin/cpi/pane.tsx +5 -4
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
- package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
- package/src/plugins/builtin/docs/blocks.tsx +13 -2
- package/src/plugins/builtin/docs/pane.tsx +6 -5
- package/src/plugins/builtin/doe/index.tsx +11 -4
- package/src/plugins/builtin/doe/pane.tsx +5 -4
- package/src/plugins/builtin/doe/prices.ts +84 -0
- package/src/plugins/builtin/doe/series.ts +4 -1
- package/src/plugins/builtin/earnings/board-model.ts +18 -1
- package/src/plugins/builtin/earnings/board-table.ts +18 -3
- package/src/plugins/builtin/earnings/headless.ts +13 -2
- package/src/plugins/builtin/earnings/history-model.ts +1 -1
- package/src/plugins/builtin/earnings/history-table.ts +2 -2
- package/src/plugins/builtin/earnings-calls/headless.ts +7 -21
- package/src/plugins/builtin/earnings-calls/index.tsx +2 -0
- package/src/plugins/builtin/earnings-calls/lock-note.ts +17 -0
- package/src/plugins/builtin/equity-screener/headless.ts +59 -1
- package/src/plugins/builtin/equity-screener/live.ts +82 -31
- package/src/plugins/builtin/equity-screener/pane.tsx +60 -23
- package/src/plugins/builtin/estimate-revisions/client.ts +1 -1
- package/src/plugins/builtin/estimate-revisions/headless.ts +9 -2
- package/src/plugins/builtin/estimate-revisions/index.tsx +3 -0
- package/src/plugins/builtin/estimate-revisions/lock-note.ts +23 -0
- package/src/plugins/builtin/executives/index.tsx +18 -0
- package/src/plugins/builtin/exposure/charts.tsx +4 -3
- package/src/plugins/builtin/filing-events/index.tsx +8 -10
- package/src/plugins/builtin/fixed-income/pane.tsx +2 -1
- package/src/plugins/builtin/gpu/equities.tsx +9 -6
- package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
- package/src/plugins/builtin/iv-history/format.ts +3 -0
- package/src/plugins/builtin/iv-history/headless.ts +3 -1
- package/src/plugins/builtin/iv-history/index.tsx +3 -1
- package/src/plugins/builtin/iv-history/model.ts +3 -1
- package/src/plugins/builtin/iv-history/screen-pane.tsx +3 -1
- package/src/plugins/builtin/iv-history/universe.ts +1 -1
- package/src/plugins/builtin/jobs/headless.ts +6 -20
- package/src/plugins/builtin/jobs/index.tsx +2 -0
- package/src/plugins/builtin/jobs/lock-note.ts +16 -0
- package/src/plugins/builtin/notes/index.tsx +1 -1
- package/src/plugins/builtin/notes/owner.tsx +2 -2
- package/src/plugins/builtin/options/carry.ts +36 -0
- package/src/plugins/builtin/options/index.tsx +2 -1
- package/src/plugins/builtin/options/model-spot.ts +94 -0
- package/src/plugins/builtin/options/open-interest-as-of.ts +74 -0
- package/src/plugins/builtin/options/view.tsx +69 -11
- package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
- package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
- package/src/plugins/builtin/options-scenario/client.ts +33 -9
- package/src/plugins/builtin/options-scenario/csp.ts +168 -0
- package/src/plugins/builtin/options-scenario/evidence.ts +3 -0
- package/src/plugins/builtin/options-scenario/headless.ts +24 -8
- package/src/plugins/builtin/options-scenario/index.tsx +3 -1
- package/src/plugins/builtin/options-scenario/pane.tsx +32 -6
- package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
- package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
- package/src/plugins/builtin/research/event-model.ts +7 -4
- package/src/plugins/builtin/risk-factors/index.tsx +18 -0
- package/src/plugins/builtin/risk-factors/pane.tsx +2 -1
- package/src/plugins/builtin/scanner/headless.ts +9 -2
- package/src/plugins/builtin/scanner/index.ts +3 -0
- package/src/plugins/builtin/scanner/lock-note.ts +17 -0
- package/src/plugins/builtin/series-inventory.ts +2 -1
- package/src/plugins/builtin/shared/cloud-denied.ts +39 -8
- package/src/plugins/builtin/shared/earnings-report.ts +10 -4
- package/src/plugins/builtin/shared/pro-lock-note.ts +58 -0
- package/src/plugins/builtin/shared/series-list-detail.tsx +10 -5
- package/src/plugins/builtin/social-mentions/client.ts +3 -3
- package/src/plugins/builtin/social-mentions/headless.ts +6 -2
- package/src/plugins/builtin/social-mentions/index.tsx +2 -1
- package/src/plugins/builtin/social-mentions/lock-note.ts +15 -0
- package/src/plugins/builtin/supply-chain/flow.tsx +15 -13
- package/src/plugins/builtin/supply-chain/graph.tsx +6 -4
- package/src/plugins/builtin/supply-chain/pane.tsx +8 -5
- package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +14 -1
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +9 -7
- package/src/plugins/builtin/ticker-detail/headless.ts +4 -1
- package/src/plugins/builtin/ticker-detail/overview/components.tsx +105 -104
- package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +34 -8
- package/src/plugins/builtin/vol-surface/pane.tsx +6 -3
- package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
- package/src/renderers/dom/data-table/index.tsx +48 -15
- package/src/renderers/dom/data-table/row.tsx +1 -1
- package/src/renderers/dom/desktop/popover.tsx +6 -15
- package/src/renderers/dom/host/input.tsx +27 -3
- package/src/renderers/dom/host/surface-3d.tsx +6 -4
- package/src/renderers/dom/styles.css +26 -6
- package/src/renderers/electrobun/view/app-services.ts +2 -22
- package/src/renderers/electrobun/view/news-source.ts +42 -0
- package/src/series/validate.ts +5 -0
- package/src/shares/dialog.tsx +2 -1
- package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
- package/src/sources/provider-router/financials.ts +5 -0
- package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
- package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
- package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
- package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
- package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
- package/src/types/data-provider.ts +3 -0
- package/src/types/financials.ts +28 -0
- package/src/types/plugin.ts +17 -4
- package/src/utils/decimal-comma.ts +18 -0
- package/src/utils/exchanges.ts +7 -0
- package/src/utils/financial-statements.ts +25 -0
- package/src/utils/forward-dividend.ts +39 -9
- package/src/utils/fundamentals.ts +41 -1
- package/src/utils/option-expiry.ts +16 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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,
|
|
501
|
-
[dividendYield,
|
|
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",
|
|
512
|
-
putGreeks: volatilities ? calculateOptionGreeks(put, "put",
|
|
513
|
-
callExtrinsicPerYear: call && volatilities ? optionCarry(call, "call",
|
|
514
|
-
putExtrinsicPerYear: put && volatilities ? optionCarry(put, "put",
|
|
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
|
|
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
|
-
|
|
246
|
-
const
|
|
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
|
|
271
|
-
*
|
|
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:
|
|
43
|
-
|
|
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,
|
|
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 } : {}),
|
|
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 } } : {}),
|