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
@@ -1,4 +1,5 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
+ import { proLockNote } from "../shared/pro-lock-note";
2
3
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
3
4
  import {
4
5
  attachExecutivesPersistence,
@@ -10,6 +11,21 @@ import { isKnownNonUsListing } from "../../../utils/sec";
10
11
  const description =
11
12
  "Named executive officers and what they were paid, read from the company's proxy statement and checked against the filing.";
12
13
 
14
+ /**
15
+ * EXEC is Pro whole (`useProFeatureWall`). The proxy statements it reads are
16
+ * in the SEC filing list, which needs no account: `filings` lists them with a
17
+ * link to each, where the Summary Compensation Table is the pay as filed,
18
+ * without EXEC's parsed figures.
19
+ */
20
+ function executivesLockNote(lock: { gate: "sign-in" | "pro"; symbol: string | null }): string {
21
+ return proLockNote(lock, {
22
+ token: "EXEC",
23
+ does: "it reads each proxy statement into the named executive officers and what they were paid",
24
+ shows: "it",
25
+ free: (ticker, company) => `gloomberb filings ${ticker} --form DEF14A lists ${company}'s proxy statements, each with its filing date and a link to the filing, whose Summary Compensation Table holds the pay as filed.`,
26
+ });
27
+ }
28
+
13
29
  export const executivesModule: PluginModule = {
14
30
  setup(ctx) {
15
31
  attachExecutivesPersistence(ctx.persistence);
@@ -31,6 +47,8 @@ export const executivesModule: PluginModule = {
31
47
  {
32
48
  id: EXECUTIVES_PANE_ID,
33
49
  reportFreshness: { source: "SEC EDGAR", status: "not-a-feed", basis: "proxy statements" },
50
+ reportLockNote: executivesLockNote,
51
+ reportRequirements: ["Gloom Cloud Pro"],
34
52
  name: "Executives",
35
53
  icon: "X",
36
54
  component: ExecutivesPane,
@@ -1,4 +1,5 @@
1
1
  import { ActionRow } from "../../../components";
2
+ import { ClippedText } from "../../../components/ui/display";
2
3
  import { Box, ChartSurface, Text, useUiCapabilities } from "../../../ui";
3
4
  import { useThemeColors } from "../../../theme/theme-context";
4
5
  import type { ChartVectorShape } from "../../../ui/host";
@@ -53,14 +54,14 @@ export function ExposurePathDiagram({ component, width, height }: { component: E
53
54
  const vectors: ChartVectorShape[] = hops.map((hop, i) => ({ id: `${i}`, points: [{ x: (i + .5) / nodes.length, y: .5 }, { x: (i + 1.5) / nodes.length, y: .5 }], color: hop.pct === null ? colors.textDim : colors.warning, strokeWidth: 2, handles: true }));
54
55
  return <Box flexDirection="column" height={height} overflow="hidden">
55
56
  <Box paddingX={1} height={1} flexShrink={0}><Text fg={colors.textDim}>{hops.length} hop{hops.length === 1 ? "" : "s"} · {component.classification}{component.exposurePct ? ` · ${rangeText(component.exposurePct)}` : ""}</Text></Box>
56
- <Box flexDirection="row" height={1} flexShrink={0} paddingX={1}>{nodes.map((node, i) => <Box key={`${node.id}:${i}`} width={nodeWidth} alignItems="center"><Text fg={colors.textBright}>{truncateToDisplayWidth(node.symbol ?? node.name, nodeWidth - 2)}</Text></Box>)}</Box>
57
+ <Box flexDirection="row" height={1} flexShrink={0} paddingX={1}>{nodes.map((node, i) => <Box key={`${node.id}:${i}`} width={nodeWidth} alignItems="center"><ClippedText fg={colors.textBright} text={node.symbol ?? node.name} width={nodeWidth - 2} /></Box>)}</Box>
57
58
  <Box height={1} flexShrink={0} paddingX={1}>
58
59
  {desktop ? <ChartSurface width={width - 2} height={1} vectors={vectors} /> : <Text fg={colors.textDim}>{nodes.map((_, i) => i < hops.length ? "─".repeat(Math.max(1, nodeWidth - 2)) + "→ " : "").join("")}</Text>}
59
60
  </Box>
60
- {height >= 4 ? <Box flexDirection="row" height={1} flexShrink={0} paddingX={1}>{nodes.map((_, i) => <Box key={i} width={nodeWidth} alignItems="center"><Text fg={colors.warning}>{i === 0 ? "" : truncateToDisplayWidth(hops[i - 1]!.pct === null ? "Undisclosed share" : `${hops[i - 1]!.pct}% ${basisLabel(hops[i - 1]!.basis)}`, nodeWidth - 1)}</Text></Box>)}</Box> : null}
61
+ {height >= 4 ? <Box flexDirection="row" height={1} flexShrink={0} paddingX={1}>{nodes.map((_, i) => <Box key={i} width={nodeWidth} alignItems="center"><ClippedText fg={colors.warning} text={i === 0 ? "" : hops[i - 1]!.pct === null ? "Undisclosed share" : `${hops[i - 1]!.pct}% ${basisLabel(hops[i - 1]!.basis)}`} width={nodeWidth - 1} /></Box>)}</Box> : null}
61
62
  {height >= 5 ? <Box flexDirection="row" height={1} flexShrink={0} paddingX={1}>{nodes.map((_, i) => {
62
63
  const evidence = i > 0 ? hops[i - 1]!.evidence[0] : null;
63
- return <Box key={i} width={nodeWidth} alignItems="center"><Text fg={colors.textDim}>{evidence ? truncateToDisplayWidth(`${evidence.tier} · ${evidence.asOf ?? "undated"}`, nodeWidth - 1) : ""}</Text></Box>;
64
+ return <Box key={i} width={nodeWidth} alignItems="center"><ClippedText fg={colors.textDim} text={evidence ? `${evidence.tier} · ${evidence.asOf ?? "undated"}` : ""} width={nodeWidth - 1} /></Box>;
64
65
  })}</Box> : null}
65
66
  </Box>;
66
67
  }
@@ -1,4 +1,5 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
+ import { proLockNote } from "../shared/pro-lock-note";
2
3
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
3
4
  import { FILING_EVENTS_PANE_ID, FilingEventsPane } from "./pane";
4
5
  import { isKnownNonUsListing } from "../../../utils/sec";
@@ -12,16 +13,13 @@ const description =
12
13
  * are in SEC, which needs no account: its rows carry each 8-K's filing date,
13
14
  * acceptance time and items, by number and in words.
14
15
  */
15
- function filingEventsLockNote({ gate, symbol }: { gate: "sign-in" | "pro"; symbol: string | null }): string {
16
- const ticker = symbol ?? "<ticker>";
17
- const unlock = gate === "sign-in"
18
- ? "To read them in EK, open the app (gloomberb), log in or sign up from the command bar (Ctrl+P, Log In or Sign Up), then upgrade (Ctrl+P, UPGRADE)"
19
- : "To read them in EK, upgrade in the app (gloomberb, then Ctrl+P, UPGRADE)";
20
- return [
21
- `EK is part of Gloom Cloud Pro: it reads each 8-K and sums up what it said and who it names. ${gate === "sign-in" ? "Signed out" : "On a free account"} it shows none of them.`,
22
- `Free, with no sign-in: gloomberb fn SEC ${ticker} lists ${symbol ?? "the company"}'s recent filings, 8-Ks among them, each with its filing date and its items by number and name.`,
23
- `${unlock}; gloomberb fn EK ${ticker} then reads with that session.`,
24
- ].join("\n");
16
+ function filingEventsLockNote(lock: { gate: "sign-in" | "pro"; symbol: string | null }): string {
17
+ return proLockNote(lock, {
18
+ token: "EK",
19
+ does: "it reads each 8-K and sums up what it said and who it names",
20
+ shows: "them",
21
+ free: (ticker, company) => `gloomberb fn SEC ${ticker} lists ${company}'s recent filings, 8-Ks among them, each with its filing date and its items by number and name.`,
22
+ });
25
23
  }
26
24
 
27
25
  export const filingEventsModule: PluginModule = {
@@ -108,6 +108,7 @@ function FixedIncomeSectionPane({ section, ...props }: PaneProps & { section: Fi
108
108
  flexGrow={1}
109
109
  flexBasis={0}
110
110
  overflow="hidden"
111
+ data-gloom-role="nested-pane-content"
111
112
  >
112
113
  <PaneFooterScope active={value === view}>
113
114
  <View {...props} paneType={paneType} height={contentHeight} focused={props.focused && value === view} />
@@ -147,7 +148,7 @@ export function FixedIncomePane(props: PaneProps) {
147
148
  <Box height={contentHeight} flexGrow={1} flexBasis={0} overflow="hidden">
148
149
  {FIXED_INCOME_SECTIONS.map(({ value }) => (
149
150
  mounted.has(value) || value === section ? (
150
- <Box key={value} visible={value === section} height={contentHeight} flexGrow={1} flexBasis={0} overflow="hidden">
151
+ <Box key={value} visible={value === section} height={contentHeight} flexGrow={1} flexBasis={0} overflow="hidden" data-gloom-role="nested-pane-content">
151
152
  <PaneFooterScope active={value === section}>
152
153
  <NestedPaneTabs>
153
154
  <FixedIncomeSectionPane {...props} section={value} height={contentHeight} focused={props.focused && value === section} />
@@ -10,9 +10,10 @@ import { useAssetData, useAsyncResource, useAutoRefresh, usePluginPaneState, use
10
10
  import { priceColor } from "../../../theme/colors";
11
11
  import { useThemeColors } from "../../../theme/theme-context";
12
12
  import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
13
- import { Box, Text, TextAttributes } from "../../../ui";
13
+ import { Box, TextAttributes, useUiCapabilities } from "../../../ui";
14
+ import { ClippedText } from "../../../components/ui/display";
14
15
  import { publicTickerKey } from "../../../utils/exchanges";
15
- import { displayWidth, truncateToDisplayWidth } from "../../../utils/format";
16
+ import { displayWidth } from "../../../utils/format";
16
17
  import { quoteBoardFooterInfo, quoteBoardStatus, renderQuoteBoardCell, useQuoteBoard } from "../shared/use-quote-board";
17
18
  import { GpuModelQuery } from "./board";
18
19
  import { loadGpuEquityHistory } from "./client";
@@ -66,6 +67,7 @@ export function GpuEquities({ board, models, locked, model, setModel, reloadBoar
66
67
  const dataRef = useRef({ quotes, historyMap });
67
68
  dataRef.current = { quotes, historyMap };
68
69
  const medium = width >= 100;
70
+ const desktop = !!useUiCapabilities().nativePaneChrome;
69
71
  const renderCell = useCallback((item: Row, column: DataTableColumn, _index: number, state: { selected: boolean }): DataTableCell => {
70
72
  if (!isSectionedItemRow(item)) return EMPTY_TABLE_CELL;
71
73
  const row = item.item;
@@ -81,9 +83,10 @@ export function GpuEquities({ board, models, locked, model, setModel, reloadBoar
81
83
  const variant = [row.gpuModel, ...gpuVariant(reference)].join(" ");
82
84
  // The name keeps its room; the variant gives way first.
83
85
  const rest = Math.max(0, column.width - displayWidth(name) - 2);
84
- return { text: `${name} ${variant}`, content: <Box flexDirection="row" height={1} overflow="hidden">
85
- <Text fg={state.selected ? colors.selectedText : colors.text}>{truncateToDisplayWidth(name, column.width)}</Text>
86
- {rest >= 4 ? <Text fg={state.selected ? colors.selectedText : colors.textDim}>{` ${truncateToDisplayWidth(variant, rest)}`}</Text> : null}
86
+ // On the desktop the variant gives way first at the column's edge, by CSS.
87
+ return { text: `${name} ${variant}`, content: <Box flexDirection="row" height={1} overflow="hidden" {...(desktop ? { flexShrink: 1, minWidth: 0 } : {})}>
88
+ <ClippedText fg={state.selected ? colors.selectedText : colors.text} text={name} width={column.width} />
89
+ {desktop || rest >= 4 ? <ClippedText fg={state.selected ? colors.selectedText : colors.textDim} lead=" " text={variant} width={rest} flex="0 1000 auto" /> : null}
87
90
  </Box> };
88
91
  }
89
92
  case "gpuPrice": return reference ? { text: gpuPrice(reference.pricePerGpuHr), value: reference.pricePerGpuHr, color: colors.textBright } : gapCell;
@@ -104,7 +107,7 @@ export function GpuEquities({ board, models, locked, model, setModel, reloadBoar
104
107
  default: return renderQuoteBoardCell(column.id as "price" | "changePercent", dataRef.current.quotes.get(row.symbol),
105
108
  { formatPrice: (quote) => gpuPrice(quote.price), formatChange: (quote) => gpuChange(quote.change) });
106
109
  }
107
- }, [colors]);
110
+ }, [colors, desktop]);
108
111
  return <DataTableView<Row> columns={equityColumns(width)} items={items} rootWidth={width} rootHeight={height} focused={focused}
109
112
  rootBefore={<GpuModelQuery models={models} model={model || "H100"} setModel={setModel} width={width} allowAll={false} />}
110
113
  selection={{ kind: "id", selectedId: selected, getId: rowKey, onChange: (id) => setSelected(String(id)) }} getItemKey={rowKey}
@@ -7,7 +7,7 @@ import { t } from "../../../i18n";
7
7
  import { colors } from "../../../theme/colors";
8
8
  import { Box, Text } from "../../../ui";
9
9
  import { formatRelativeTime } from "../../../utils/datetime-format";
10
- import { requestAuthDialog } from "../cloud/auth-dialog";
10
+ import { requestAuthDialog } from "../../../account/auth-dialog";
11
11
 
12
12
  const STATUS_BADGES: Record<FeedbackStatus, { label: string; tone: "neutral" | "accent" | "positive" }> = {
13
13
  received: { label: "Received", tone: "neutral" },
@@ -21,6 +21,9 @@ export const VCA_DEFINITIONS = [
21
21
  */
22
22
  export const skewHeader = (stem: string, sign: string) => `${stem} ${sign} (pts)`;
23
23
 
24
+ /** The underlying price a reading was taken at, to the cent. */
25
+ export const formatSpot = (value: number | null | undefined) => value == null || !Number.isFinite(value) ? "--"
26
+ : value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
24
27
  export const formatVol = (value: number | null | undefined) => value == null || !Number.isFinite(value) ? "--" : `${(value * 100).toFixed(1)}%`;
25
28
  export const formatPoints = (value: number | null | undefined) =>
26
29
  value == null || !Number.isFinite(value) ? "--" : `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}`;
@@ -2,7 +2,7 @@ import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import { resolveHeadlessInstrument } from "../shared/headless-market-data";
3
3
  import { createRealizedVolatilityDependencies, loadRealizedVolatilityHistory } from "../realized-vol/client";
4
4
  import { createHvDependencies, loadIvHistory, loadIvScreen, loadRealizedVolatilities } from "./client";
5
- import { formatPoints, formatRank, formatStat, formatVol, readingLabel, sharedDates, skewHeader, VCA_DEFINITIONS, verdictLabel } from "./format";
5
+ import { formatPoints, formatRank, formatSpot, formatStat, formatVol, readingLabel, sharedDates, skewHeader, VCA_DEFINITIONS, verdictLabel } from "./format";
6
6
  import { HV_WINDOWS, type HvWindow, type IvLookback, type IvStatRow, projectIvHistory, projectRichCheap, type RichCheapRow, richCheapDates, screenCoverage, VCA_LIMIT, VCA_PRESETS } from "./model";
7
7
  import { vcaUniverse } from "./universe";
8
8
  import { listingFateNotes, loadListingFate } from "../../../cli/listing-arg";
@@ -120,6 +120,8 @@ export const ivScreenHeadless: HeadlessPaneDefinition<"bundle"> = {
120
120
  { key: "symbol", header: "Symbol" }, { key: "iv30", header: "IV30", format: percent },
121
121
  ...(dates.reading ? [] : [{ key: "date", header: "As of", format: (value: unknown, row?: unknown) =>
122
122
  typeof value === "string" ? readingLabel(value, (row as RichCheapRow).method) : "--" }]),
123
+ // The underlying price each reading was taken at, on the date the title or the As of column gives.
124
+ { key: "spot", header: "Spot", format: (value: unknown) => typeof value === "number" ? formatSpot(value) : "--" },
123
125
  ...(dates.rankApart ? [{ key: "rankDate", header: "Rank as of", format: (value: unknown) => typeof value === "string" ? `${value} close` : "--" }] : []),
124
126
  { key: "rank", header: "IVR", format: (value: unknown) => formatRank(value as number | null) },
125
127
  { key: "percentile", header: "IVP", format: (value: unknown) => formatRank(value as number | null) },
@@ -37,7 +37,9 @@ export const ivHistoryModule: PluginModule = {
37
37
  {
38
38
  id: "iv-screen-pane", paneId: "iv-screen", label: "Volatility Rich/Cheap",
39
39
  description: "IV rank, percentile, one-year IV, term slope, skew and IV/HV across a list, ranked rich to cheap.",
40
- keywords: ["vca", "rich", "cheap", "iv rank", "implied volatility", "screen"],
40
+ // Where a put seller finds rich premium; OSA owns the exact put-selling phrases and ranks first on them.
41
+ keywords: ["vca", "rich", "cheap", "iv rank", "implied volatility", "screen", "cash-secured puts", "selling puts", "short-put candidates",
42
+ "csp candidates", "wheel candidates"],
41
43
  shortcut: { prefix: "VCA", argKind: "ticker-list" as const, argOptional: true }, headless: ivScreenHeadless,
42
44
  createInstance: (_context, options) => ({
43
45
  title: "VCA", placement: "floating" as const,
@@ -241,6 +241,8 @@ export interface RichCheapRow {
241
241
  iv30: number | null;
242
242
  date: string | null;
243
243
  method: IvMethod | null;
244
+ /** The underlying price the reading was taken at: the session's close, or the spot at the quote capture. */
245
+ spot: number | null;
244
246
  rank: number | null;
245
247
  percentile: number | null;
246
248
  /** Trade close the rank is measured on: the newest trade-close session, which a newer quote reading can be ahead of. */
@@ -304,7 +306,7 @@ export function projectRichCheap(rows: readonly IvScreenRow[], hv: ReadonlyMap<s
304
306
  return {
305
307
  symbol: row.symbol, status: row.coverageStatus ?? row.status, queuedAt: row.coverage?.addedAt ?? null,
306
308
  backfilledThrough: row.coverage?.backfilledThrough ?? null, warning: row.warnings?.[0] ?? null,
307
- iv30, date: latest?.date ?? null, method: latest?.method ?? null,
309
+ iv30, date: latest?.date ?? null, method: latest?.method ?? null, spot: latest?.spot ?? null,
308
310
  rank: row.iv30?.rank ?? null, percentile, rankDate: row.iv30?.date ?? null,
309
311
  termSlope: iv30 != null && latest?.iv90 != null ? iv30 - latest.iv90 : null,
310
312
  iv1y: latest?.iv365 ?? null,
@@ -9,7 +9,7 @@ import { wrapTextLines } from "../../../utils/text-wrap";
9
9
  import { useAutoRefresh } from "../../../react/auto-refresh";
10
10
  import { compareSortValues, nextHeaderSort, type SortDirection } from "../../../utils/sort-values";
11
11
  import { loadIvScreen, loadRealizedVolatilities } from "./client";
12
- import { formatPoints, formatRank, formatVol, readingLabel, sharedDates, shortDate, VCA_DEFINITIONS, verdictLabel } from "./format";
12
+ import { formatPoints, formatRank, formatSpot, formatVol, readingLabel, sharedDates, shortDate, VCA_DEFINITIONS, verdictLabel } from "./format";
13
13
  import { projectRichCheap, type RichCheapRow, richCheapDates, screenPaneNotices, type VcaPreset } from "./model";
14
14
  import { vcaUniverse } from "./universe";
15
15
 
@@ -21,6 +21,7 @@ const COLUMNS: DataTableColumn[] = [
21
21
  { id: "symbol", label: "Symbol", width: 8, align: "left" },
22
22
  { id: "iv30", label: "IV30", width: 7, align: "right" },
23
23
  { id: "date", label: "As of", width: 11, align: "right" },
24
+ { id: "spot", label: "Spot", width: 9, align: "right" },
24
25
  { id: "rankDate", label: "Rank as of", width: 11, align: "right" },
25
26
  { id: "rank", label: "IVR", width: 5, align: "right" },
26
27
  { id: "percentile", label: "IVP", width: 5, align: "right" },
@@ -99,6 +100,7 @@ export function IvScreenPane({ width, height, focused }: PaneProps) {
99
100
  switch (id) {
100
101
  case "symbol": return { text: row.symbol, color: colors.textBright };
101
102
  case "iv30": return { text: formatVol(row.iv30), color: colors.warning };
103
+ case "spot": return { text: formatSpot(row.spot), color: colors.textDim };
102
104
  case "date": return { text: row.date ? readingLabel(row.date, row.method, true)
103
105
  : row.status === "queued" || row.status === "backfilling" ? row.status : "--", color: colors.textDim };
104
106
  case "rankDate": return { text: row.rankDate ? `${shortDate(row.rankDate)} close` : "--", color: colors.textDim };
@@ -34,7 +34,7 @@ export function vcaUniverse(scope: string, symbolsText: string, collectionId: st
34
34
  }
35
35
  const valid = [...new Map(instruments.filter((instrument) => SYMBOL.test(instrument.symbol)).map((instrument) => [instrument.symbol, instrument])).values()];
36
36
  const error = !valid.length ? scope === "collection" ? "Link a watchlist or portfolio with US-listed symbols in pane settings." : "Add symbols in pane settings."
37
- : valid.length > VCA_LIMIT ? `Showing the first ${VCA_LIMIT} of ${valid.length} symbols.`
37
+ : valid.length > VCA_LIMIT ? `Only the first ${VCA_LIMIT} symbols are screened.`
38
38
  : skipped ? `${skipped} non-US listing${skipped === 1 ? "" : "s"} left out: stored implied volatility covers US option underlyings.` : null;
39
39
  return { label, instruments: valid.slice(0, VCA_LIMIT), error };
40
40
  }
@@ -1,11 +1,12 @@
1
+ import { ApiRequestError } from "../../../api-client/errors";
1
2
  import type {
2
3
  HeadlessBundleResult,
3
- HeadlessPaneContext,
4
4
  HeadlessPaneDefinition,
5
5
  } from "../../../types/plugin";
6
- import { cloudAccountNeed, cloudDeniedStatus, type CloudDeniedStatus } from "../shared/cloud-denied";
6
+ import { explainRefusal } from "../shared/cloud-denied";
7
7
  import { formatCompact, formatNumber } from "../../../utils/format";
8
8
  import { fetchJobs, fetchJobsMovers, type JobsCloudClient, type JobsCompanyState } from "./client";
9
+ import { jobsLockNote } from "./lock-note";
9
10
  import {
10
11
  buildMoverRows,
11
12
  buildPostingRows,
@@ -15,18 +16,6 @@ import {
15
16
  functionLabel,
16
17
  } from "./model";
17
18
 
18
- /**
19
- * What a refused account needs, and the free preview to try instead: HIRE
20
- * reads the same careers systems, and is named only when it answers for this
21
- * company.
22
- */
23
- async function jobsDeniedMessage(symbol: string | undefined, status: CloudDeniedStatus, client: Pick<HeadlessPaneContext["apiClient"], "getCloudHiring">): Promise<string> {
24
- const need = cloudAccountNeed(status);
25
- const preview = await client.getCloudHiring(symbol, { limit: 1 })
26
- .then((payload) => !!payload && (symbol ? "symbol" in payload : "companies" in payload), () => false);
27
- return `JOBS needs ${need}${preview ? `; HIRE${symbol ? ` ${symbol}` : ""} is free to preview` : ""}.`;
28
- }
29
-
30
19
  function companyBundle(symbol: string, state: Exclude<JobsCompanyState, { kind: "denied" }>, roleLimit: number): HeadlessBundleResult {
31
20
  if (state.kind === "pending") {
32
21
  return {
@@ -193,14 +182,11 @@ export const jobsHeadless: HeadlessPaneDefinition<"bundle"> = {
193
182
  const symbol = args.symbols[0];
194
183
  if (symbol) {
195
184
  const state = await fetchJobs(symbol, { force: true, client: ctx.apiClient as JobsCloudClient });
196
- if (state.kind === "denied") throw new Error(await jobsDeniedMessage(symbol, state.status, ctx.apiClient));
185
+ if (state.kind === "denied") throw explainRefusal(new ApiRequestError(state.message, state.status), jobsLockNote, { symbol });
197
186
  return companyBundle(symbol, state, limit);
198
187
  }
199
- const payload = await fetchJobsMovers(ctx.apiClient as JobsCloudClient).catch(async (error: unknown) => {
200
- const status = cloudDeniedStatus(error);
201
- if (status) throw new Error(await jobsDeniedMessage(undefined, status, ctx.apiClient));
202
- throw error;
203
- });
188
+ const payload = await fetchJobsMovers(ctx.apiClient as JobsCloudClient)
189
+ .catch((error: unknown) => { throw explainRefusal(error, jobsLockNote, { symbol: null }); });
204
190
  const rows = buildMoverRows(payload.movers).slice(0, Math.max(limit, 15)).map((row) => ({
205
191
  ticker: row.ticker,
206
192
  company: row.company,
@@ -3,6 +3,7 @@ import type { PluginModule } from "../plugin-module";
3
3
  import { JOBS_SERIES } from "./series";
4
4
  import { resetJobsCache } from "./client";
5
5
  import { jobsHeadless } from "./headless";
6
+ import { jobsLockNote } from "./lock-note";
6
7
  import { JOBS_PANE_ID, JobsPane, JobsResearchTab } from "./pane";
7
8
 
8
9
 
@@ -33,6 +34,7 @@ export const jobsModule: PluginModule = {
33
34
  panes: [
34
35
  {
35
36
  id: JOBS_PANE_ID,
37
+ reportLockNote: jobsLockNote,
36
38
  name: "Hiring",
37
39
  icon: "J",
38
40
  component: JobsPane,
@@ -0,0 +1,16 @@
1
+ import type { PaneLock } from "../../../types/plugin";
2
+ import { proLockNote } from "../shared/pro-lock-note";
3
+
4
+ /**
5
+ * JOBS is Pro whole. HIRE reads the same careers systems and needs no
6
+ * account: a company's latest roles, without the full history.
7
+ */
8
+ export function jobsLockNote(lock: PaneLock): string {
9
+ return proLockNote(lock, {
10
+ token: "JOBS",
11
+ argument: "optional",
12
+ does: "it reads each company's careers system into open roles, hiring pace and the mix by function and country",
13
+ shows: "it",
14
+ free: (ticker, company) => `gloomberb fn HIRE ${ticker} previews ${company}'s hiring with its latest roles, without the full history.`,
15
+ });
16
+ }
@@ -1,6 +1,6 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import type { GloomPlugin } from "../../../types/plugin";
3
- import { teamStore } from "../cloud/team/store";
3
+ import { teamStore } from "../../../team/store";
4
4
  import { notesCliCommand } from "./cli";
5
5
  import { exportNotesToDirectory, exportSourceLabel } from "./export";
6
6
  import { NotesFiles } from "./files";
@@ -5,8 +5,8 @@ import { QueryBar } from "../../../components/ui/query-bar";
5
5
  import { Box, Text } from "../../../ui";
6
6
  import type { DialogApi, PromptContext } from "../../../ui/dialog";
7
7
  import { colors } from "../../../theme/colors";
8
- import { teamAccentHex, teamPrefix } from "../cloud/team/model";
9
- import { teamStore } from "../cloud/team/store";
8
+ import { teamAccentHex, teamPrefix } from "../../../team/model";
9
+ import { teamStore } from "../../../team/store";
10
10
  import { NoteConflictError, type NoteOwner, noteOwnerKey } from "./store";
11
11
 
12
12
  export function useNoteTeams(): TeamSummary[] {
@@ -48,6 +48,42 @@ export function optionCarry(
48
48
  return { extrinsic, extrinsicPerYear: days >= MIN_CARRY_DAYS ? extrinsic / spot / (days / 365) : null };
49
49
  }
50
50
 
51
+ /**
52
+ * Extrinsic per year net of financing and dividends, a fraction of spot per
53
+ * year (`gloomberb options --leaps --columns carry-net`):
54
+ *
55
+ * call: (extrinsic - strike x rate x years + spot x dividend yield x years) / spot / years
56
+ * put: (extrinsic + strike x rate x years - spot x dividend yield x years) / spot / years
57
+ *
58
+ * A call bought instead of the stock leaves the strike in cash earning the rate
59
+ * and forgoes the dividends; a put's midpoint already has both in it the other
60
+ * way round (put-call parity), so the signs turn. Simple interest over the same
61
+ * years as `optionCarry`. Null when the carry has no yearly figure.
62
+ */
63
+ export function optionCarryNet(
64
+ carry: OptionCarry,
65
+ side: OptionSide,
66
+ strike: number,
67
+ spot: number,
68
+ rate: number,
69
+ dividendYield: number,
70
+ ): number | null {
71
+ if (carry.extrinsicPerYear == null || !positive(spot) || !positive(strike)) return null;
72
+ const financing = rate * strike / spot - dividendYield;
73
+ return side === "call" ? carry.extrinsicPerYear - financing : carry.extrinsicPerYear + financing;
74
+ }
75
+
76
+ /**
77
+ * Where the underlying has to finish for a contract bought at its midpoint to
78
+ * pay back at expiry: strike plus the midpoint for a call, strike less it for a
79
+ * put. Null without a two-sided quote.
80
+ */
81
+ export function optionBreakeven(contract: Pick<OptionContract, "bid" | "ask" | "strike">, side: OptionSide): number | null {
82
+ const mid = optionMid(contract);
83
+ if (mid == null || !positive(contract.strike)) return null;
84
+ return side === "call" ? contract.strike + mid : contract.strike - mid;
85
+ }
86
+
51
87
  /** Bid/ask width as a percent of the midpoint; null without a two-sided quote. */
52
88
  export function optionSpreadPercent(contract: Pick<OptionContract, "bid" | "ask">): number | null {
53
89
  const spread = optionSpread(contract);
@@ -135,7 +135,8 @@ export const optionsModule: PluginModule = {
135
135
  paneId: "options",
136
136
  label: "Options",
137
137
  description: "Options chain for the selected ticker.",
138
- keywords: ["options", "chain", "calls", "puts", "omon"],
138
+ // Where a put seller picks the strike; OSA owns the exact put-selling phrases and ranks first on them.
139
+ keywords: ["options", "chain", "calls", "puts", "omon", "cash-secured puts", "selling puts", "short-put strikes", "csp strikes", "wheel strikes"],
139
140
  shortcut: "OMON",
140
141
  settings: (_symbol, _context, options) => {
141
142
  const expiration = readOptionExpiration(options?.values?.expiration);
@@ -0,0 +1,94 @@
1
+ import { getPublishedUsEquitySession } from "../../../market-data/published-us-sessions";
2
+ import type { OptionsChain, Quote } from "../../../types/financials";
3
+ import { zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
4
+ import { optionQuoteValuationTime } from "../vol-surface/model";
5
+
6
+ /**
7
+ * The underlying price and instant a chain's IV and Greeks are valued at
8
+ * (docs/research-data.md#greeks-spot-and-time). The chain's quotes and the
9
+ * spot belong to one moment:
10
+ *
11
+ * - when the quote carries the official close of the regular session the
12
+ * chain's quotes were taken in (a chain from the close, read before the next
13
+ * open or early in it), that close, at the chain's time;
14
+ * - otherwise the underlying's latest price, at the chain's time once its
15
+ * quotes are over an hour old (outside the session) and now while they are
16
+ * current.
17
+ *
18
+ * So a pre-market print never moves the delta of a chain still quoting the
19
+ * last close.
20
+ */
21
+ export interface OptionModelSpot {
22
+ price: number;
23
+ /** When the price was observed: the session's close, or the quote's own time. */
24
+ observedAt: number;
25
+ /** The instant time to expiry is measured from. */
26
+ valuationTime: number;
27
+ /** `close`: the regular session's official close; `last`: the latest price. */
28
+ basis: "close" | "last";
29
+ /** The session the latest price is from, when it is not the regular one. */
30
+ session: "pre-market" | "after hours" | "overnight" | null;
31
+ }
32
+
33
+ const newYorkDate = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit" });
34
+ const positive = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
35
+
36
+ function regularCloseTime(exchange: string | undefined, date: string): number {
37
+ const session = getPublishedUsEquitySession(exchange || "NYSE", date);
38
+ if (session?.kind === "session" && Number.isFinite(session.close)) return session.close;
39
+ const [year, month, day] = date.split("-").map(Number) as [number, number, number];
40
+ return zonedWallClockToUtcMs("America/New_York", year, month, day, 16, 0, 0);
41
+ }
42
+
43
+ /** The session a quote's price is from when it is not the regular one; null in the regular session or when nothing traded outside it. */
44
+ export function extendedSession(quote: Quote): OptionModelSpot["session"] {
45
+ if (quote.overnightPrice != null && quote.price === quote.overnightPrice) return "overnight";
46
+ if (quote.extendedSessionPrint === false) return null;
47
+ if (quote.marketState === "PRE" || quote.marketState === "PREPRE") return "pre-market";
48
+ if (quote.marketState === "POST" || quote.marketState === "POSTPOST") return "after hours";
49
+ return null;
50
+ }
51
+
52
+ /** The spot the chain is valued at; null without a current underlying price. */
53
+ export function optionModelSpot(
54
+ quote: Quote | null | undefined,
55
+ chain: Pick<OptionsChain, "asOf"> | null | undefined,
56
+ now: number,
57
+ exchange?: string,
58
+ ): OptionModelSpot | null {
59
+ if (!quote || quote.stale === true || !positive(quote.price)) return null;
60
+ const chainTime = chain?.asOf ? Date.parse(chain.asOf) : Number.NaN;
61
+ const chainDate = Number.isFinite(chainTime) ? newYorkDate.format(chainTime) : null;
62
+ if (chainDate && positive(quote.regularClose) && quote.regularCloseSessionDate === chainDate && chainTime <= now) {
63
+ return { price: quote.regularClose, observedAt: regularCloseTime(exchange, chainDate), valuationTime: chainTime,
64
+ basis: "close", session: null };
65
+ }
66
+ return { price: quote.price, observedAt: positive(quote.lastUpdated) ? quote.lastUpdated : now,
67
+ valuationTime: optionQuoteValuationTime(chain ?? {}, now), basis: "last", session: extendedSession(quote) };
68
+ }
69
+
70
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
71
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
72
+
73
+ /** `Fri 9 Oct` for a calendar date, YYYY-MM-DD. */
74
+ export function formatCalendarDay(date: string): string {
75
+ const day = new Date(`${date}T00:00:00Z`);
76
+ return `${WEEKDAYS[day.getUTCDay()]} ${day.getUTCDate()} ${MONTHS[day.getUTCMonth()]}`;
77
+ }
78
+
79
+ /** `Mon 12 Oct 20:00 UTC`. */
80
+ export function formatSpotTime(time: number): string {
81
+ const date = new Date(time);
82
+ return `${WEEKDAYS[date.getUTCDay()]} ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]} ${date.toISOString().slice(11, 16)} UTC`;
83
+ }
84
+
85
+ /** How a footer names the spot: `close Mon 12 Oct 20:00 UTC`, `pre-market Tue 13 Oct 12:21 UTC`, `last Tue 13 Oct 14:05 UTC`. */
86
+ export function modelSpotLabel(spot: Pick<OptionModelSpot, "basis" | "session" | "observedAt">): string {
87
+ const word = spot.basis === "close" ? "close" : spot.session ?? "last";
88
+ return `${word} ${formatSpotTime(spot.observedAt)}`;
89
+ }
90
+
91
+ /** A price to the cent, or to the tick it was quoted at below a dollar. */
92
+ export function formatModelSpotPrice(price: number): string {
93
+ return price.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: price < 1 ? 4 : 2, useGrouping: false });
94
+ }
@@ -0,0 +1,74 @@
1
+ import type { OptionsChain } from "../../../types/financials";
2
+ import { formatCalendarDay } from "./model-spot";
3
+ import { loadOpenInterest, type OpenInterestPayload, type OptionsPositioningApi } from "../options-positioning/client";
4
+
5
+ /**
6
+ * The session a chain's open interest is counted at
7
+ * (docs/research-data.md#greeks-spot-and-time). Gloom Cloud states it on the
8
+ * chain (`openInterestAsOf`). A chain that does not say, from an older server,
9
+ * borrows OPX's listed date, the session whose close its counts are, only when
10
+ * the chain's counts are OPX's: every contract with a count in both agrees.
11
+ * Otherwise the date is not stated.
12
+ */
13
+ export interface OpenInterestAsOf {
14
+ /** The session the chain's counts are as of, YYYY-MM-DD; null when it cannot be established. */
15
+ date: string | null;
16
+ /** The session OPX's counts are as of. */
17
+ listedDate: string | null;
18
+ /** Contracts with a count in both the chain and OPX's listing. */
19
+ compared: number;
20
+ /** Of those, the ones whose counts agree. */
21
+ matched: number;
22
+ }
23
+
24
+ /**
25
+ * The date the chain states for its counts: a session, or null when none of
26
+ * its counts is dated. Undefined when the chain does not say, and only then is
27
+ * OPX's listing compared.
28
+ */
29
+ export function statedOpenInterestAsOf(chain: Pick<OptionsChain, "openInterestAsOf">): OpenInterestAsOf | undefined {
30
+ const stated = chain.openInterestAsOf;
31
+ if (stated !== null && (typeof stated !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(stated))) return undefined;
32
+ return { date: stated, listedDate: null, compared: 0, matched: 0 };
33
+ }
34
+
35
+ export function chainOpenInterestAsOf(
36
+ chain: Pick<OptionsChain, "calls" | "puts">,
37
+ listed: Pick<OpenInterestPayload, "oiDate" | "strikes">,
38
+ ): OpenInterestAsOf {
39
+ const byStrike = new Map(listed.strikes.map((row) => [row.strike, row]));
40
+ let compared = 0;
41
+ let matched = 0;
42
+ for (const [side, contracts] of [["callOI", chain.calls], ["putOI", chain.puts]] as const) {
43
+ for (const contract of contracts) {
44
+ const count = byStrike.get(contract.strike)?.[side];
45
+ if (contract.openInterest == null || count == null) continue;
46
+ compared += 1;
47
+ if (count === contract.openInterest) matched += 1;
48
+ }
49
+ }
50
+ return { date: listed.oiDate && compared > 0 && matched === compared ? listed.oiDate : null,
51
+ listedDate: listed.oiDate, compared, matched };
52
+ }
53
+
54
+ /** How long a chain waits for OPX's listing before it leaves the open-interest date unstated. */
55
+ const LISTED_TIMEOUT_MS = 8_000;
56
+
57
+ /** OPX's listed open interest for one expiry; null when it cannot be read in time or names another expiry. */
58
+ export async function loadListedOpenInterest(
59
+ symbol: string,
60
+ expiry: string,
61
+ options: { signal?: AbortSignal; api?: OptionsPositioningApi } = {},
62
+ ): Promise<OpenInterestPayload | null> {
63
+ try {
64
+ const listed = await loadOpenInterest(symbol, expiry, { signal: options.signal ?? AbortSignal.timeout(LISTED_TIMEOUT_MS) }, options.api);
65
+ return listed.expiry === expiry ? listed : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** `OI as of Fri 9 Oct`, or `OI date not published` when the date cannot be established. */
72
+ export function openInterestAsOfLabel(asOf: Pick<OpenInterestAsOf, "date"> | null): string {
73
+ return asOf?.date ? `OI as of ${formatCalendarDay(asOf.date)}` : "OI date not published";
74
+ }