gloomberb 0.17.1 → 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 (101) 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/earnings.ts +4 -2
  4. package/src/app/global-shortcuts.ts +10 -0
  5. package/src/app/keybindings/actions.ts +16 -0
  6. package/src/app/runtime/desktop-deeplink.ts +1 -1
  7. package/src/app.tsx +1 -1
  8. package/src/architecture/core-builtin-allowlist.json +4 -9
  9. package/src/cli/commands/market.ts +34 -16
  10. package/src/cli/commands/options-chain.ts +29 -33
  11. package/src/cli/commands/options-leaps.ts +462 -63
  12. package/src/cli/commands/search.ts +10 -7
  13. package/src/cli/commands/ticker.ts +1 -1
  14. package/src/cli/known-gaps.ts +22 -0
  15. package/src/cli/pane-functions/catalog.ts +6 -3
  16. package/src/cli/pane-functions/dom.ts +17 -2
  17. package/src/cli/pane-functions/function-help.ts +1 -1
  18. package/src/cli/pane-functions/index.ts +5 -3
  19. package/src/cli/pane-functions/report-notices.ts +15 -0
  20. package/src/cli/symbol-args.ts +19 -0
  21. package/src/components/command-bar/workflow/builtin.ts +2 -2
  22. package/src/components/command-bar/workflow/collection-actions.ts +1 -1
  23. package/src/components/feedback-dialog.tsx +1 -1
  24. package/src/components/layout/shell/index.tsx +1 -1
  25. package/src/components/layout/status-bar-groups.ts +1 -1
  26. package/src/components/layout/status-bar.tsx +2 -2
  27. package/src/components/markdown-text.tsx +14 -2
  28. package/src/components/reader/article-reader.tsx +1 -1
  29. package/src/components/reader/detail-reader.tsx +10 -7
  30. package/src/components/reader/reader-text.tsx +32 -17
  31. package/src/layout-marketplace/gallery.tsx +2 -2
  32. package/src/layout-marketplace/linked-sync.ts +1 -1
  33. package/src/market-data/market/freshness.ts +3 -0
  34. package/src/market-data/published-apac-sessions.ts +12 -2
  35. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  36. package/src/plugins/builtin/chart-composer/presets.ts +33 -1
  37. package/src/plugins/builtin/chat/channel-labels.ts +2 -2
  38. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
  39. package/src/plugins/builtin/chat/content/index.tsx +2 -2
  40. package/src/plugins/builtin/chat/conversations.ts +1 -1
  41. package/src/plugins/builtin/chat/index.tsx +2 -2
  42. package/src/plugins/builtin/chat/share/flow.tsx +1 -1
  43. package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
  44. package/src/plugins/builtin/chat/sidebar.tsx +2 -2
  45. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
  46. package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
  47. package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
  48. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
  49. package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
  50. package/src/plugins/builtin/cloud/team/capability.ts +2 -2
  51. package/src/plugins/builtin/cloud/team/command.ts +4 -4
  52. package/src/plugins/builtin/cloud/team/module.tsx +3 -3
  53. package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
  54. package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
  55. package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
  56. package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
  57. package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
  58. package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
  59. package/src/plugins/builtin/cloud/team/views.ts +2 -2
  60. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
  61. package/src/plugins/builtin/cloud/thesis/detail.tsx +1 -1
  62. package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
  63. package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
  64. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  65. package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
  66. package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
  67. package/src/plugins/builtin/docs/blocks.tsx +13 -2
  68. package/src/plugins/builtin/docs/pane.tsx +6 -5
  69. package/src/plugins/builtin/earnings/board-model.ts +18 -1
  70. package/src/plugins/builtin/earnings/board-table.ts +18 -3
  71. package/src/plugins/builtin/earnings/headless.ts +13 -2
  72. package/src/plugins/builtin/earnings/history-model.ts +1 -1
  73. package/src/plugins/builtin/earnings/history-table.ts +2 -2
  74. package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
  75. package/src/plugins/builtin/notes/index.tsx +1 -1
  76. package/src/plugins/builtin/notes/owner.tsx +2 -2
  77. package/src/plugins/builtin/options/carry.ts +36 -0
  78. package/src/plugins/builtin/options/open-interest-as-of.ts +16 -5
  79. package/src/plugins/builtin/options/view.tsx +19 -7
  80. package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
  81. package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
  82. package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
  83. package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
  84. package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
  85. package/src/plugins/builtin/research/event-model.ts +7 -4
  86. package/src/plugins/builtin/series-inventory.ts +1 -0
  87. package/src/plugins/builtin/shared/earnings-report.ts +10 -4
  88. package/src/renderers/dom/styles.css +4 -2
  89. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  90. package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
  91. package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
  92. package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
  93. package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
  94. package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
  95. package/src/types/data-provider.ts +3 -0
  96. package/src/types/financials.ts +6 -0
  97. package/src/utils/decimal-comma.ts +18 -0
  98. package/src/utils/exchanges.ts +7 -0
  99. package/src/utils/forward-dividend.ts +39 -9
  100. package/src/utils/option-expiry.ts +16 -0
  101. package/src/utils/sec.ts +2 -1
@@ -2,10 +2,13 @@ import type { DataTableCell, DataTableColumn } from "../../../components";
2
2
  import { TABLE_COLUMN_GAP, tableColumnWidth } from "../../../components/ui/table-layout";
3
3
  import { colors } from "../../../theme/colors";
4
4
  import { TextAttributes } from "../../../ui";
5
+ import { epsBasisShort, showsEpsBasis } from "../shared/earnings-report";
5
6
  import type { BoardRow, BoardSort } from "./board-model";
6
7
  import { epsText, impliedText, moneyText, moveSize, TIMING_LABEL, weekday } from "./format";
7
8
 
8
- type BoardColumnId = "owned" | "symbol" | "name" | "when" | "cap" | "epsEstimate" | "epsActual" | "revenueEstimate" | "implied" | "average";
9
+ type BoardColumnId =
10
+ | "owned" | "symbol" | "name" | "when" | "status" | "cap" | "epsEstimate" | "analysts" | "epsActual" | "basis"
11
+ | "revenueEstimate" | "implied" | "average";
9
12
 
10
13
  export type BoardColumn = DataTableColumn & { id: BoardColumnId };
11
14
 
@@ -16,16 +19,19 @@ const ALL_COLUMNS: BoardColumn[] = [
16
19
  { id: "symbol", label: "TICKER", width: 6, align: "left" },
17
20
  { id: "name", label: "NAME", width: NAME_MIN_WIDTH, align: "left" },
18
21
  { id: "when", label: "WHEN", width: 7, align: "left" },
22
+ { id: "status", label: "STATUS", width: 9, align: "left" },
19
23
  { id: "implied", label: "IMPLIED", width: 7, align: "right" },
20
24
  { id: "average", label: "AVG MOVE", width: 8, align: "right" },
21
25
  { id: "epsEstimate", label: "EPS EST", width: 7, align: "right" },
26
+ { id: "analysts", label: "ESTS", width: 4, align: "right" },
22
27
  { id: "epsActual", label: "EPS ACT", width: 7, align: "right" },
28
+ { id: "basis", label: "BASIS", width: 5, align: "left" },
23
29
  { id: "revenueEstimate", label: "SALES EST", width: 9, align: "right" },
24
30
  { id: "cap", label: "MKT CAP", width: 8, align: "right" },
25
31
  ];
26
32
 
27
- /** Dropped first when narrow; the implied move and its average stay beside the name. */
28
- const DROP_ORDER: BoardColumnId[] = ["epsActual", "cap", "revenueEstimate", "epsEstimate", "owned"];
33
+ /** Dropped first when narrow, the report's labels before its figures; the implied move and its average stay beside the name. */
34
+ const DROP_ORDER: BoardColumnId[] = ["basis", "analysts", "status", "epsActual", "cap", "revenueEstimate", "epsEstimate", "owned"];
29
35
 
30
36
  /** Header clicks sort these; the rest keep the ranking. */
31
37
  export const SORTABLE: Partial<Record<BoardColumnId, BoardSort["column"]>> = {
@@ -66,14 +72,23 @@ export function renderBoardCell(row: BoardRow, column: BoardColumn): DataTableCe
66
72
  // An expected time, from the company's last reports, reads quieter than an announced one.
67
73
  return { text: row.showDay ? `${weekday(report.date)} ${timing}` : timing, color: report.expectedTiming ? colors.textMuted : dim };
68
74
  }
75
+ case "status":
76
+ // A projected date reads quieter than an announced one, as an expected time does.
77
+ return { text: report.dateStatus ?? "--", color: report.dateStatus === "projected" ? colors.textMuted : dim };
69
78
  case "cap":
70
79
  return { text: moneyText(report.marketCap), color: dim };
71
80
  case "epsEstimate":
72
81
  return { text: epsText(report.epsEstimate), color: dim };
82
+ case "analysts":
83
+ return { text: report.epsAnalysts == null ? "--" : String(report.epsAnalysts), color: dim };
73
84
  case "epsActual": {
74
85
  const beat = report.epsActual != null && report.epsEstimate != null ? report.epsActual - report.epsEstimate : null;
75
86
  return { text: report.epsActual == null ? "" : epsText(report.epsActual), color: beat == null || beat === 0 ? colors.text : beat > 0 ? colors.positive : colors.negative };
76
87
  }
88
+ case "basis":
89
+ return !showsEpsBasis(report)
90
+ ? { text: "" }
91
+ : { text: epsBasisShort(report.epsBasis), color: report.epsBasis ? colors.textMuted : dim };
77
92
  case "revenueEstimate":
78
93
  return { text: moneyText(report.revenueEstimate), color: dim };
79
94
  case "implied":
@@ -13,7 +13,7 @@ import { fetchEarningsCalendar, fetchEarningsHistory } from "./client";
13
13
  import { loadEarningsCalendar } from "./data/cache";
14
14
  import { epsText, impliedText, moneyText, moveSize, signedPercent, TIMING_LABEL } from "./format";
15
15
  import { historyRows } from "./history-model";
16
- import { EPS_BASIS_NOTE, epsBasisName } from "../shared/earnings-report";
16
+ import { EPS_BASIS_NOTE, epsBasisName, showsEpsBasis } from "../shared/earnings-report";
17
17
  import { isUsListingExchange } from "../../../utils/exchanges";
18
18
  import { listingAbroadVenue } from "../../../utils/sec";
19
19
  import { findUsListingOfCompany } from "../../../tickers/us-listing";
@@ -24,17 +24,23 @@ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(va
24
24
 
25
25
  const BOARD_COLUMNS: HeadlessPaneColumn[] = [
26
26
  { key: "date", header: "Date" },
27
+ { key: "dateStatus", header: "Status" },
27
28
  { key: "timing", header: "When" },
28
29
  { key: "symbol", header: "Ticker" },
29
30
  { key: "name", header: "Name" },
30
31
  { key: "marketCap", header: "Mkt cap", align: right, format: (value) => moneyText(num(value)) },
31
32
  { key: "epsEstimate", header: "EPS est", align: right, format: (value) => epsText(num(value)) },
33
+ { key: "epsAnalysts", header: "Ests", align: right },
32
34
  { key: "epsActual", header: "EPS act", align: right, format: (value) => epsText(num(value)) },
35
+ { key: "epsBasis", header: "Basis" },
33
36
  { key: "revenueEstimate", header: "Sales est", align: right, format: (value) => moneyText(num(value)) },
34
37
  { key: "impliedMove", header: "Implied", align: right, format: (value) => impliedText(num(value)) },
35
38
  { key: "averageMove", header: "Avg move", align: right, format: (value) => moveSize(num(value)) },
36
39
  ];
37
40
 
41
+ /** Where the board's caps come from and how old they can be, in its metadata. */
42
+ const MARKET_CAP_NOTE = "Each company's market cap as RV shows it: its latest financials snapshot at the regular session's price (the close outside the session), from the newest quote Gloom Cloud holds for it. A company with no snapshot shows the earnings calendar's figure.";
43
+
38
44
  const HISTORY_COLUMNS: HeadlessPaneColumn[] = [
39
45
  { key: "date", header: "Date" },
40
46
  { key: "dateStatus", header: "Status" },
@@ -105,13 +111,16 @@ async function boardRows(args: HeadlessPaneLoadArgs, context: HeadlessPaneContex
105
111
  const all = [...reports, ...extra].sort((left, right) => left.date.localeCompare(right.date) || (right.marketCap ?? 0) - (left.marketCap ?? 0));
106
112
  const rows = all.slice(0, limit).map((report) => ({
107
113
  date: report.date,
114
+ dateStatus: report.dateStatus,
108
115
  timing: report.timing ? TIMING_LABEL[report.timing] : null,
109
116
  timingExpected: report.expectedTiming,
110
117
  symbol: report.symbol,
111
118
  name: report.name,
112
119
  marketCap: report.marketCap,
113
120
  epsEstimate: report.epsEstimate,
121
+ epsAnalysts: report.epsAnalysts,
114
122
  epsActual: report.epsActual,
123
+ epsBasis: showsEpsBasis(report) ? epsBasisName(report.epsBasis) : null,
115
124
  revenueEstimate: report.revenueEstimate,
116
125
  impliedMove: report.impliedMove,
117
126
  averageMove: report.averageMove,
@@ -127,6 +136,8 @@ async function boardRows(args: HeadlessPaneLoadArgs, context: HeadlessPaneContex
127
136
  from: query.from,
128
137
  to: query.to,
129
138
  averageMoveOver: `last 8 reports, at least ${MIN_AVERAGE_REPORTS}`,
139
+ epsBasis: EPS_BASIS_NOTE,
140
+ marketCap: MARKET_CAP_NOTE,
130
141
  total: all.length,
131
142
  returned: rows.length,
132
143
  truncated: rows.length < all.length,
@@ -168,7 +179,7 @@ async function historyReport(symbol: string, context: HeadlessPaneContext, deps:
168
179
  epsEstimate: row.epsEstimate,
169
180
  epsAnalysts: row.epsAnalysts,
170
181
  epsActual: row.epsActual,
171
- epsBasis: row.epsActual == null ? null : epsBasisName(row.epsBasis),
182
+ epsBasis: showsEpsBasis(row) ? epsBasisName(row.epsBasis) : null,
172
183
  epsSurprise: row.epsSurprise,
173
184
  revenueEstimate: row.revenueEstimate,
174
185
  revenueActual: row.revenueActual,
@@ -87,7 +87,7 @@ export function historyRows(payload: EarningsHistoryPayload | null, today: strin
87
87
  epsEstimate: coherentEarningsValue(upcoming, "epsEstimate"),
88
88
  epsAnalysts: coherentEarningsValue(upcoming, "epsAnalysts"),
89
89
  epsActual: null,
90
- epsBasis: null,
90
+ epsBasis: earningsEpsBasis(upcoming.epsBasis),
91
91
  epsSurprise: null,
92
92
  revenueEstimate: coherentEarningsValue(upcoming, "revenueEstimate"),
93
93
  revenueActual: null,
@@ -3,7 +3,7 @@ import { TABLE_COLUMN_GAP, tableColumnWidth } from "../../../components/ui/table
3
3
  import { colors } from "../../../theme/colors";
4
4
  import { TextAttributes } from "../../../ui";
5
5
  import { datedYear, epsText, fiscalMonth, impliedText, moneyText, signedPercent, TIMING_LABEL } from "./format";
6
- import { epsBasisShort } from "../shared/earnings-report";
6
+ import { epsBasisShort, showsEpsBasis } from "../shared/earnings-report";
7
7
  import type { HistoryRow } from "./history-model";
8
8
 
9
9
  type HistoryColumnId =
@@ -74,7 +74,7 @@ export function renderHistoryCell(row: HistoryRow, column: HistoryColumn): DataT
74
74
  case "epsActual":
75
75
  return { text: epsText(row.epsActual), color: colors.text };
76
76
  case "basis":
77
- return row.epsActual == null
77
+ return !showsEpsBasis(row)
78
78
  ? { text: "--", color: dim }
79
79
  : { text: epsBasisShort(row.epsBasis), color: row.epsBasis ? colors.textMuted : dim };
80
80
  case "epsSurprise":
@@ -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" },
@@ -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);
@@ -4,11 +4,11 @@ import { loadOpenInterest, type OpenInterestPayload, type OptionsPositioningApi
4
4
 
5
5
  /**
6
6
  * The session a chain's open interest is counted at
7
- * (docs/research-data.md#greeks-spot-and-time). The chain carries no date for
8
- * its counts; OPX's listed open interest does, the session whose close its
9
- * counts are. That date is the chain's only when the chain's counts are
10
- * OPX's: every contract with a count in both agrees. Otherwise the date is not
11
- * stated, never borrowed.
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
12
  */
13
13
  export interface OpenInterestAsOf {
14
14
  /** The session the chain's counts are as of, YYYY-MM-DD; null when it cannot be established. */
@@ -21,6 +21,17 @@ export interface OpenInterestAsOf {
21
21
  matched: number;
22
22
  }
23
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
+
24
35
  export function chainOpenInterestAsOf(
25
36
  chain: Pick<OptionsChain, "calls" | "puts">,
26
37
  listed: Pick<OpenInterestPayload, "oiDate" | "strikes">,
@@ -6,7 +6,9 @@ import { isPlainKey } from "../../../utils/keyboard";
6
6
  import { formatCompact, formatPercentRaw } from "../../../utils/format";
7
7
  import { formatMarketPrice, formatSignedMarketPrice, liveQuoteFormatOptions } from "../../../market-data/market/format";
8
8
  import { formatExpDate, resolveOptionsTarget } from "../../../utils/options";
9
- import { expiryIsoDate, findListedExpiry, formatDaysToExpiry, daysToExpiry, missingExpiryText, readOptionExpiration } from "../../../utils/option-expiry";
9
+ import {
10
+ expiryIsoDate, findListedExpiry, formatDaysToExpiry, daysToExpiry, missingExpiryMessage, missingExpiryText, readOptionExpiration,
11
+ } from "../../../utils/option-expiry";
10
12
  import { canonicalTickerKey } from "../../../utils/exchanges";
11
13
  import { isOptionsUnavailableError, listedOptionsAlternative, optionsUnavailableTitle } from "../../../market-data/options-alternatives";
12
14
  import { useChartQueries, useOptionsQuery, useResolvedEntryValue, useTickerFinancials } from "../../../market-data/hooks";
@@ -73,7 +75,7 @@ import { useOptionsSessionOpen, useThrottledValue } from "../shared/volatility/l
73
75
  import { useRemoteUiNode } from "../../../remote/semantic-tree";
74
76
  import { optionCarry, optionCostOfSpot } from "./carry";
75
77
  import { formatModelSpotPrice, modelSpotLabel, optionModelSpot } from "./model-spot";
76
- import { chainOpenInterestAsOf, loadListedOpenInterest, openInterestAsOfLabel } from "./open-interest-as-of";
78
+ import { chainOpenInterestAsOf, loadListedOpenInterest, openInterestAsOfLabel, statedOpenInterestAsOf } from "./open-interest-as-of";
77
79
  import { useAsyncResource } from "../../../react/async-resource";
78
80
  import {
79
81
  ALL_STRIKES,
@@ -402,7 +404,7 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
402
404
  const expiryAsOfMs = Number.isFinite(chainAsOfMs) ? chainAsOfMs : Date.now();
403
405
  const expiryDate = selectedExpiration == null ? "" : expiryIsoDate(selectedExpiration);
404
406
  const expiryDays = selectedExpiration == null ? "" : formatDaysToExpiry(selectedExpiration, expiryAsOfMs);
405
- useRemoteUiNode(expiryDate && chain ? {
407
+ useRemoteUiNode(expiryDate && chain && !expirationUnavailable ? {
406
408
  role: "report-notice",
407
409
  label: "Expiry",
408
410
  getMetadata: () => ({
@@ -411,6 +413,12 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
411
413
  value: { date: expiryDate, daysToExpiry: daysToExpiry(selectedExpiration!, expiryAsOfMs) },
412
414
  }),
413
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);
414
422
  // A refresh that failed keeps the last chain, which is then stale.
415
423
  const asOfEntry = strikeChain == null ? null : strikeChain === expirationChain ? expirationChainEntry : initialChainEntry;
416
424
  const chainDelay = asOfChain?.delayMinutes ?? 0;
@@ -529,12 +537,16 @@ export function OptionsView({ width, height, focused, nestedInTabs = false, ivRa
529
537
  isPositionStrike: !!parsed && strike === parsed.strike,
530
538
  };
531
539
  }), [callsByStrike, dividendYield, greeksSpot, parsed, putsByStrike, spot, strikes, volatilities]);
532
- // The open interest's session, from OPX's listing of the same expiry, stated only when the counts are OPX's.
533
- const listedExpiry = selectedExpiration == null || !effectiveTicker ? null : expiryIsoDate(selectedExpiration);
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);
534
545
  const listedLoader = useCallback(() => loadListedOpenInterest(effectiveTicker, listedExpiry!), [effectiveTicker, listedExpiry]);
535
546
  const listedOpenInterest = useAsyncResource(listedExpiry ? listedLoader : null);
536
- const openInterestAsOf = useMemo(() => strikeChain && listedOpenInterest.data ? chainOpenInterestAsOf(strikeChain, listedOpenInterest.data) : null,
537
- [strikeChain, listedOpenInterest.data]);
547
+ const openInterestAsOf = useMemo(() => statedOpenInterest
548
+ ?? (strikeChain && listedOpenInterest.data ? chainOpenInterestAsOf(strikeChain, listedOpenInterest.data) : null),
549
+ [statedOpenInterest, strikeChain, listedOpenInterest.data]);
538
550
  // A report of the pane (`gloomberb fn OMON`) names them too.
539
551
  useRemoteUiNode(modelSpot ? {
540
552
  role: "report-notice",
@@ -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
+ }
@@ -2,7 +2,7 @@ import type { AppConfig } from "../../../types/config";
2
2
  import type { Portfolio, PortfolioCash, TickerPosition, TickerRecord, Watchlist } from "../../../types/ticker";
3
3
  import { resolveCurrencyUnit } from "../../../utils/currency-units";
4
4
  import { slugifyName } from "../../../utils/slugify";
5
- import { parseCollectionRef } from "../cloud/team/collections";
5
+ import { parseCollectionRef } from "../../../team/collections";
6
6
  import { findNamedCollection } from "../../../portfolio/collection-name";
7
7
 
8
8
  export interface DeleteWatchlistResult {
@@ -2,7 +2,7 @@ import { t, tf } from "../../../../i18n";
2
2
  import { formatMarketCost, formatMarketQuantity } from "../../../../market-data/market/format";
3
3
  import type { AppConfig } from "../../../../types/config";
4
4
  import type { TickerRecord } from "../../../../types/ticker";
5
- import { parseCollectionRef } from "../../cloud/team/collections";
5
+ import { parseCollectionRef } from "../../../../team/collections";
6
6
  import { isManualPortfolio, removeTickerFromPortfolio, removeTickerFromWatchlist } from "../mutations";
7
7
 
8
8
  export interface RemovableCollection {
@@ -285,9 +285,11 @@ export function buildEventDetail({
285
285
  const comparison: EventDetailBlock[] = [
286
286
  ...(row.dateStatus && row.dateStatus !== "reported" ? [detailRow("Report date", row.date, row.dateStatus)] : []),
287
287
  detailRow("Actual", earningsInput(row.epsActual, row.epsCurrency),
288
- row.epsBasis === "unknown" ? "basis unknown" : row.epsBasis),
288
+ row.epsActual == null ? undefined : row.epsBasis === "unknown" ? "basis unknown" : row.epsBasis),
289
+ // An upcoming estimate names its basis when it is adjusted (a REIT's FFO consensus).
289
290
  detailRow("Consensus", earningsInput(row.epsEstimate, row.epsCurrency),
290
- row.epsAnalysts != null ? `${row.epsAnalysts} analysts` : undefined),
291
+ [row.epsAnalysts != null ? `${row.epsAnalysts} analysts` : null,
292
+ row.epsActual == null && row.epsBasis === "adjusted" ? "adjusted" : null].filter(Boolean).join(", ") || undefined),
291
293
  ];
292
294
  if (row.epsDifference != null) comparison.push(detailRow("Difference", earningsInput(row.epsDifference, row.epsCurrency)));
293
295
  if (row.surprisePercent != null) {
@@ -20,6 +20,7 @@ import {
20
20
  epsBasisName,
21
21
  epsSurprise,
22
22
  inFiscalMonth,
23
+ showsEpsBasis,
23
24
  type EpsBasisName,
24
25
  } from "../shared/earnings-report";
25
26
 
@@ -46,7 +47,7 @@ export interface EventRow {
46
47
  epsEstimate?: number;
47
48
  epsDifference?: number;
48
49
  surprisePercent?: number;
49
- /** A reported figure's basis; the same quarter is named the same way in ERN. */
50
+ /** A reported figure's basis, or an upcoming estimate's when it is adjusted; the same quarter is named the same way in ERN. */
50
51
  epsBasis?: EpsBasisName;
51
52
  /** Analysts behind `epsEstimate`, when known. */
52
53
  epsAnalysts?: number;
@@ -224,11 +225,12 @@ function ttmRow(
224
225
  };
225
226
  }
226
227
 
227
- /** A reported row names its basis and beat; a pending one whether its date is announced and its analysts. */
228
+ /** A reported row names its basis and beat; a pending one whether its date is announced, its analysts and an adjusted estimate's basis. */
228
229
  function earningsDetail(earning: CorporateActionsData["earnings"][number]): string {
229
230
  if (earning.epsActual == null) {
230
231
  const status = earningsDateStatus(earning.dateStatus);
231
- const parts = [status === "reported" ? null : status, earning.epsAnalysts != null ? `${earning.epsAnalysts} analysts` : null];
232
+ const parts = [status === "reported" ? null : status, earning.epsAnalysts != null ? `${earning.epsAnalysts} analysts` : null,
233
+ earningsEpsBasis(earning.epsBasis) === "adjusted" ? BASIS_TEXT.adjusted : null];
232
234
  return parts.filter(Boolean).join(", ") || "Pending";
233
235
  }
234
236
  const basis = BASIS_TEXT[epsBasisName(earningsEpsBasis(earning.epsBasis))];
@@ -341,7 +343,8 @@ export function buildEventRows(
341
343
  epsEstimate: earning.epsEstimate,
342
344
  epsDifference: earning.epsActual == null ? undefined : earning.difference,
343
345
  surprisePercent,
344
- epsBasis: earning.epsActual == null ? undefined : epsBasisName(earningsEpsBasis(earning.epsBasis)),
346
+ epsBasis: showsEpsBasis({ epsActual: earning.epsActual, epsBasis: earningsEpsBasis(earning.epsBasis) })
347
+ ? epsBasisName(earningsEpsBasis(earning.epsBasis)) : undefined,
345
348
  epsAnalysts: earning.epsAnalysts,
346
349
  dateStatus: earningsDateStatus(earning.dateStatus) ?? undefined,
347
350
  providerId: data?.providerId,
@@ -37,6 +37,7 @@ export const SERIES_INVENTORY: readonly SeriesInventoryEntry[] = [
37
37
  { module: "realized-vol", chart: "Realized volatility cone and rolling windows", notInCatalog: "derived", reason: "The composer's realized volatility study draws it from any price series." },
38
38
  { module: "vol-surface", chart: "Implied volatility surface by strike and expiry", notInCatalog: "not-a-time-series", reason: "A surface over strike and tenor." },
39
39
  { module: "options-positioning", chart: "Open interest and gamma by strike and expiry", notInCatalog: "not-a-time-series", reason: "Plotted by strike and expiry, not by date." },
40
+ { module: "options-calculator", chart: "A linked option contract's daily premium", series: ["option:*"] },
40
41
  { module: "options-scenario", chart: "Option payoff by underlying price", notInCatalog: "not-a-time-series", reason: "A payoff curve over prices." },
41
42
  { module: "kelly-sizer", chart: "Position size against win rate", notInCatalog: "account", reason: "Drawn from the inputs typed into the sizer." },
42
43
  { module: "yield-curve", chart: "Treasury curve and each maturity's history", series: ["ust:*", "fred:T10Y2Y", "fred:T10Y3M", "fred:DFII*"] },
@@ -2,9 +2,10 @@ import type { EarningsDateStatus, EarningsEpsBasis } from "../../../types/financ
2
2
 
3
3
  /**
4
4
  * How every earnings surface (ERN, EE, the corporate-actions events and
5
- * their headless rows) reads and names a reported quarter's EPS basis and a
6
- * report date's status. Gloom Cloud resolves one figure per quarter; this
7
- * keeps the panes from naming it differently.
5
+ * their headless rows) reads and names a reported quarter's EPS basis, an
6
+ * upcoming estimate's when Gloom Cloud names one, and a report date's
7
+ * status. Gloom Cloud resolves one figure per quarter; this keeps the panes
8
+ * from naming it differently.
8
9
  */
9
10
 
10
11
  const BASES: ReadonlySet<string> = new Set<EarningsEpsBasis>(["gaap", "adjusted"]);
@@ -27,13 +28,18 @@ export function dateStatusFromEstimateFlag(isDateEstimate: boolean | null | unde
27
28
  export type EpsBasisName = "GAAP" | "adjusted" | "unknown";
28
29
 
29
30
  /** What the basis names mean, in every report's metadata. */
30
- export const EPS_BASIS_NOTE = "One reported EPS per quarter. GAAP: the quarter's diluted statement EPS. adjusted: the consensus-basis figure the estimate is measured against, which leaves out items the statements include. unknown: no statement on a comparable share basis to tell.";
31
+ export const EPS_BASIS_NOTE = "One reported EPS per quarter. GAAP: the quarter's diluted statement EPS. adjusted: the consensus-basis figure the estimate is measured against, which leaves out items the statements include. unknown: no statement on a comparable share basis to tell. An upcoming report's estimate is labelled adjusted when it is the consensus on the adjusted basis the company's reports are on (a REIT's FFO), not a GAAP forecast.";
31
32
 
32
33
  /** The basis as rows, exports and tools name it. */
33
34
  export function epsBasisName(basis: EarningsEpsBasis | null): EpsBasisName {
34
35
  return basis === "gaap" ? "GAAP" : basis === "adjusted" ? "adjusted" : "unknown";
35
36
  }
36
37
 
38
+ /** Whether a row names a basis: a reported figure always does, an upcoming estimate only when Gloom Cloud labels it. */
39
+ export function showsEpsBasis(row: { epsActual: number | null | undefined; epsBasis: EarningsEpsBasis | null | undefined }): boolean {
40
+ return row.epsActual != null || row.epsBasis != null;
41
+ }
42
+
37
43
  /** The basis in a table cell or a detail line. */
38
44
  export function epsBasisShort(basis: EarningsEpsBasis | null): string {
39
45
  return basis === "gaap" ? "GAAP" : basis === "adjusted" ? "adj" : "?";
@@ -1280,14 +1280,16 @@ html[data-gloom-screenshot="true"] [data-gloom-role="chart-watermark"] {
1280
1280
  }
1281
1281
 
1282
1282
  /* Reading columns (src/components/reader) set their type from their own
1283
- width, not from a count of cells: 1.45em, or 1.35em under 480px. */
1283
+ width, not from a count of cells: body text at 1.1667em of the app's size
1284
+ (14px), or 1.1em (13.2px) under 480px. The rest of the reader's scale is in
1285
+ em of it (reader-text.tsx). */
1284
1286
  [data-gloom-ui="article-reader"],
1285
1287
  [data-gloom-ui="detail-reader"] {
1286
1288
  container-type: inline-size;
1287
1289
  }
1288
1290
  @container (max-width: 480px) {
1289
1291
  [data-gloom-ui="reader-column"] {
1290
- --reader-em: 1.35;
1292
+ --reader-em: 1.1;
1291
1293
  }
1292
1294
  }
1293
1295
  /* A detail's facts: label and value pairs, two pairs to a row when the
@@ -311,6 +311,8 @@ export function mapOptionsChain(
311
311
  delayMinutes: chain.delayMinutes,
312
312
  realtimeEligible: chain.realtimeEligible,
313
313
  asOf: chain.asOf,
314
+ // Left absent when the server does not send it, so the date is then established against OPX.
315
+ ...(chain.openInterestAsOf !== undefined ? { openInterestAsOf: chain.openInterestAsOf } : {}),
314
316
  };
315
317
  }
316
318