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,31 +1,55 @@
1
- import type { OptionContract, OptionsChain } from "../../types/financials";
1
+ import type { OptionContract, OptionsChain, TickerFinancials } from "../../types/financials";
2
2
  import type { CliCommandContext } from "../../types/plugin";
3
3
  import type { MarketContext } from "../types";
4
4
  import type { CliResultColumn } from "../result";
5
5
  import { listingReadNotes, requireCliListing, loadListingQuote, type CliListing } from "../listing-arg";
6
6
  import { rowsFreshness } from "../freshness";
7
7
  import { formatCountCell } from "../helpers";
8
- import { cliStyles } from "../../utils/cli-output";
8
+ import { cliStyles, renderTable, type CliTableColumn } from "../../utils/cli-output";
9
9
  import { errorMessage } from "../../utils/errors";
10
- import { daysToExpiryFrom, type OptionSide } from "../../plugins/builtin/shared/volatility";
10
+ import { numberErrorMessage, rangeErrorMessage } from "../../utils/decimal-comma";
11
+ import { expiryIsoDate, OPTION_EXPIRATION_FORMAT, parseOptionExpiration } from "../../utils/option-expiry";
12
+ import { isFundType } from "../../utils/sec";
13
+ import { zonedDateKey } from "../../utils/zoned-date-time";
14
+ import { daysToExpiryFrom, optionMid, type OptionSide } from "../../plugins/builtin/shared/volatility";
11
15
  import { optionQuoteValuationTime } from "../../plugins/builtin/vol-surface/model";
12
- import { optionCarry, optionSpreadPercent } from "../../plugins/builtin/options/carry";
16
+ import { optionBreakeven, optionCarry, optionCarryNet, optionSpreadPercent } from "../../plugins/builtin/options/carry";
13
17
  import { parseDeltaBand } from "../../plugins/builtin/options/strike-window";
14
- import { chainWithModelFigures, loadOptionModelInputs, OPTION_MODEL_RATE, type OptionModelInputs } from "./options-chain";
18
+ import { ivSymbol, loadIvScreen, type IvScreenRow } from "../../plugins/builtin/iv-history/client";
19
+ import {
20
+ chainWithModelFigures, loadOptionModelInputs, loadUnderlyingFinancials, OPTION_MODEL_RATE, type OptionModelInputs,
21
+ } from "./options-chain";
15
22
  import { takeFlag, takeOption } from "./command-utils";
16
23
 
17
24
  /**
18
- * `gloomberb options <symbol...> --leaps`: contracts more than a year out
19
- * ranked across symbols for a stock replacement, by what the time value costs
20
- * per year. Delta, IV and carry come from the same solver and formulas as the
21
- * options pane (`carry.ts`, `analytics.ts`).
25
+ * `gloomberb options <symbol...> --leaps`: long-dated contracts ranked across
26
+ * symbols for a stock replacement, by what the time value costs per year.
27
+ * Delta, IV and carry come from the same solver and formulas as the options
28
+ * pane (`carry.ts`, `analytics.ts`); IV rank, the next report and the
29
+ * ex-dividend date from the same sources as OMON, OVDV and DES.
22
30
  */
23
- export const LEAPS_USAGE = "options <symbol...> --leaps [--side calls|puts] [--delta <low-high>] [--max-spread <percent>] [--min-oi <contracts>] [--sort <order>]";
31
+ export const LEAPS_USAGE = "options <symbol...> --leaps [--side calls|puts] [--delta <low-high>] [--max-spread <percent>] "
32
+ + "[--min-oi <contracts>] [--min-days <days>] [--max-days <days>] [--expiry-from <date>] [--expiry-to <date>] "
33
+ + "[--columns <list>] [--sort <order>]";
24
34
 
25
35
  export type LeapsSort = "carry" | "spread" | "oi" | "delta" | "expiry" | "symbol";
26
36
  const LEAPS_SORTS: readonly LeapsSort[] = ["carry", "spread", "oi", "delta", "expiry", "symbol"];
27
37
 
28
- export interface LeapsCriteria {
38
+ /** What `--columns` adds, in the order the table and the exports show them. */
39
+ export const LEAPS_EXTRA_COLUMNS = ["iv", "ivr", "cost", "breakeven", "next-earnings", "ex-dividend", "carry-net"] as const;
40
+ type LeapsExtraColumn = (typeof LEAPS_EXTRA_COLUMNS)[number];
41
+
42
+ /** Which expiries the screen reads: every bound given, all at once. */
43
+ interface LeapsWindow {
44
+ /** Fewest whole calendar days to expiry, as the Days column counts them; null with only a date for a lower bound. */
45
+ minDays: number | null;
46
+ maxDays: number | null;
47
+ /** First and last expiry date kept, YYYY-MM-DD. */
48
+ expiryFrom: string | null;
49
+ expiryTo: string | null;
50
+ }
51
+
52
+ export interface LeapsCriteria extends LeapsWindow {
29
53
  side: OptionSide;
30
54
  minDelta: number;
31
55
  maxDelta: number;
@@ -33,8 +57,13 @@ export interface LeapsCriteria {
33
57
  maxSpreadPercent: number;
34
58
  minOpenInterest: number;
35
59
  sort: LeapsSort;
60
+ /** What `--columns` added; nothing beyond the screen's own figures is read without it. */
61
+ columns: LeapsExtraColumn[];
36
62
  }
37
63
 
64
+ /** A LEAPS expiry is at least a year out, unless the window says where it starts. */
65
+ const DEFAULT_MIN_DAYS = 365;
66
+
38
67
  export const DEFAULT_LEAPS_CRITERIA: LeapsCriteria = {
39
68
  side: "call",
40
69
  minDelta: 0.7,
@@ -42,10 +71,13 @@ export const DEFAULT_LEAPS_CRITERIA: LeapsCriteria = {
42
71
  maxSpreadPercent: 5,
43
72
  minOpenInterest: 100,
44
73
  sort: "carry",
74
+ minDays: DEFAULT_MIN_DAYS,
75
+ maxDays: null,
76
+ expiryFrom: null,
77
+ expiryTo: null,
78
+ columns: [],
45
79
  };
46
80
 
47
- /** A LEAPS expiry is more than this many calendar days out. */
48
- const LEAPS_MIN_DAYS = 365;
49
81
  /** Most symbols one run takes, and most expiries read per symbol, so a run stays a few dozen requests. */
50
82
  const MAX_LEAPS_SYMBOLS = 20;
51
83
  const MAX_LEAPS_EXPIRIES = 8;
@@ -55,21 +87,122 @@ const LEAPS_TEXT_ROWS = 40;
55
87
  const LEAPS_CHAIN_CONCURRENCY = 4;
56
88
  // Delta prints to three places; a contract that reads 0.700 is in a 0.70 band.
57
89
  const DELTA_TOLERANCE = 5e-4;
90
+ /** Shares one listed equity option delivers. */
91
+ const SHARES_PER_CONTRACT = 100;
92
+
93
+ const LEAPS_FLAGS = [
94
+ "--side", "--delta", "--max-spread", "--min-oi", "--min-days", "--max-days", "--expiry-from", "--expiry-to", "--columns", "--sort",
95
+ ] as const;
58
96
 
59
- /** `--leaps` and the options that only mean something with it, taken out of `args`. */
97
+ /**
98
+ * `--leaps` and the options that only mean something with it, taken out of
99
+ * `args`. One given without a value reads as empty, which its parser refuses.
100
+ */
60
101
  export function takeLeapsOptions(args: string[]): { leaps: boolean; raw: Record<string, string | undefined>; given: string[] } {
61
102
  const given: string[] = [];
62
103
  const raw: Record<string, string | undefined> = {};
63
- for (const flag of ["--side", "--delta", "--max-spread", "--min-oi", "--sort"]) {
64
- if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`))) given.push(flag);
65
- raw[flag] = takeOption(args, flag);
104
+ for (const flag of LEAPS_FLAGS) {
105
+ const present = args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
106
+ if (present) given.push(flag);
107
+ const value = takeOption(args, flag);
108
+ raw[flag] = present ? value ?? "" : undefined;
66
109
  }
67
110
  return { leaps: takeFlag(args, "--leaps"), raw, given };
68
111
  }
69
112
 
70
- /** The screen's criteria from its options; a message naming the option when one cannot be read. */
71
- export function parseLeapsCriteria(raw: Record<string, string | undefined>): LeapsCriteria | { error: string } {
72
- const criteria = { ...DEFAULT_LEAPS_CRITERIA };
113
+ /** A count typed as digits; a thousands comma (1,000) is named so it can be dropped. */
114
+ function parseWholeNumber(flag: string, value: string, unit: string, example: number): number | { error: string } {
115
+ const text = value.trim();
116
+ if (/^\d+$/.test(text) && Number.isSafeInteger(Number(text))) return Number(text);
117
+ if (/^\d{1,3}(?:,\d{3})+$/.test(text)) {
118
+ return { error: `Invalid ${flag} "${value}". Use a whole number of ${unit} without separators: ${flag} ${text.replace(/,/g, "")}.` };
119
+ }
120
+ return { error: `Invalid ${flag} "${value}". Use a whole number of ${unit}, such as ${flag} ${example}.` };
121
+ }
122
+
123
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
124
+
125
+ function parseExpiryDate(flag: string, value: string, example: string): string | { error: string } {
126
+ const text = value.trim();
127
+ if (ISO_DATE.test(text) && parseOptionExpiration(text) != null) return text;
128
+ return { error: `Invalid ${flag} "${value}". Use a date as YYYY-MM-DD, such as ${flag} ${example}.` };
129
+ }
130
+
131
+ /** Whole calendar days to an expiry date's close, as the Days column counts them. */
132
+ function wholeDaysTo(date: string, now: number): number {
133
+ return Math.floor(daysToExpiryFrom(parseOptionExpiration(date)!, now));
134
+ }
135
+
136
+ /** A lower bound to suggest under an upper one of `days`. */
137
+ const lowerBoundExample = (days: number) => (days >= 180 ? 180 : 0);
138
+
139
+ /**
140
+ * The window from `--min-days`, `--max-days`, `--expiry-from`, `--expiry-to`
141
+ * and `--expiration` (one expiry): every bound given applies. Without a lower
142
+ * bound of its own it starts where LEAPS do, a year out. A window no expiry can
143
+ * be in is refused with the option to change.
144
+ */
145
+ function parseLeapsWindow(raw: Record<string, string | undefined>, now: number): LeapsWindow | { error: string } {
146
+ const window: LeapsWindow = { minDays: null, maxDays: null, expiryFrom: null, expiryTo: null };
147
+ for (const [flag, key] of [["--min-days", "minDays"], ["--max-days", "maxDays"]] as const) {
148
+ const value = raw[flag];
149
+ if (value == null) continue;
150
+ const days = parseWholeNumber(flag, value, "calendar days to expiry", key === "minDays" ? 180 : 730);
151
+ if (typeof days !== "number") return days;
152
+ window[key] = days;
153
+ }
154
+ for (const [flag, key, example] of [["--expiry-from", "expiryFrom", "2027-06-18"], ["--expiry-to", "expiryTo", "2028-06-16"]] as const) {
155
+ const value = raw[flag];
156
+ if (value == null) continue;
157
+ const date = parseExpiryDate(flag, value, example);
158
+ if (typeof date !== "string") return date;
159
+ window[key] = date;
160
+ }
161
+ const expiration = raw["--expiration"];
162
+ if (expiration != null) {
163
+ if (window.expiryFrom != null || window.expiryTo != null) {
164
+ return { error: "--expiration reads one expiry; give it alone, or a range with --expiry-from and --expiry-to." };
165
+ }
166
+ const seconds = parseOptionExpiration(expiration);
167
+ if (seconds == null) return { error: `Invalid --expiration "${expiration}". Use ${OPTION_EXPIRATION_FORMAT}.` };
168
+ window.expiryFrom = window.expiryTo = expiryIsoDate(seconds);
169
+ }
170
+ const fromFlag = expiration != null ? "--expiration" : "--expiry-from";
171
+ const toFlag = expiration != null ? "--expiration" : "--expiry-to";
172
+ const { minDays, maxDays, expiryFrom: from, expiryTo: to } = window;
173
+ if (minDays != null && maxDays != null && minDays > maxDays) {
174
+ return { error: `--min-days ${minDays} is more than --max-days ${maxDays}. The window runs from the fewer days to the more, such as --min-days ${maxDays} --max-days ${minDays}.` };
175
+ }
176
+ if (from != null && to != null && from > to) {
177
+ return { error: `--expiry-from ${from} is after --expiry-to ${to}. The window runs from the earlier date to the later, such as --expiry-from ${to} --expiry-to ${from}.` };
178
+ }
179
+ if (to != null && daysToExpiryFrom(parseOptionExpiration(to)!, now) <= 0) {
180
+ return { error: `${toFlag} ${to} has passed. Use an expiry still ahead.` };
181
+ }
182
+ // With no lower bound of its own the window starts where LEAPS do.
183
+ const floored = minDays == null && from == null;
184
+ if (floored) window.minDays = DEFAULT_MIN_DAYS;
185
+ if (floored && maxDays != null && maxDays < DEFAULT_MIN_DAYS) {
186
+ return { error: `--max-days ${maxDays} is under the ${DEFAULT_MIN_DAYS} days --leaps starts from. Add a lower bound, such as --min-days ${lowerBoundExample(maxDays)} --max-days ${maxDays}.` };
187
+ }
188
+ if (to != null && window.minDays != null && wholeDaysTo(to, now) < window.minDays) {
189
+ const days = wholeDaysTo(to, now);
190
+ return { error: floored
191
+ ? `${toFlag} ${to} is ${days} days out, under the ${DEFAULT_MIN_DAYS} days --leaps starts from. Add a lower bound, such as --min-days ${lowerBoundExample(days)}.`
192
+ : `${toFlag} ${to} is ${days} days out, under --min-days ${window.minDays}. Lower --min-days or move ${toFlag} later.` };
193
+ }
194
+ if (from != null && maxDays != null && wholeDaysTo(from, now) > maxDays) {
195
+ return { error: `${fromFlag} ${from} is ${wholeDaysTo(from, now)} days out, past --max-days ${maxDays}. Raise --max-days or move ${fromFlag} earlier.` };
196
+ }
197
+ return window;
198
+ }
199
+
200
+ /**
201
+ * The screen's criteria from its options (and `--expiration`, which picks one
202
+ * expiry); a message naming the option, with an example, when one cannot be read.
203
+ */
204
+ export function parseLeapsCriteria(raw: Record<string, string | undefined>, now = Date.now()): LeapsCriteria | { error: string } {
205
+ const criteria: LeapsCriteria = { ...DEFAULT_LEAPS_CRITERIA, columns: [] };
73
206
  const side = raw["--side"]?.trim().toLowerCase();
74
207
  if (side != null) {
75
208
  if (side === "call" || side === "calls") criteria.side = "call";
@@ -79,26 +212,39 @@ export function parseLeapsCriteria(raw: Record<string, string | undefined>): Lea
79
212
  const delta = raw["--delta"];
80
213
  if (delta != null) {
81
214
  const band = parseDeltaBand(delta);
82
- if (!band) return { error: `Invalid --delta "${delta}". Use a band of absolute deltas such as 0.70-0.90.` };
215
+ // 0,80-0,90 is a decimal comma at each end: the band to type is 0.80-0.90.
216
+ if (!band) return { error: rangeErrorMessage(`Invalid --delta "${delta}". Use a band of absolute deltas such as 0.70-0.90.`, delta) };
83
217
  criteria.minDelta = band.min;
84
218
  criteria.maxDelta = band.max;
85
219
  }
86
220
  const spread = raw["--max-spread"];
87
221
  if (spread != null) {
88
- const value = Number(spread.trim().replace(/%$/, ""));
89
- if (!spread.trim() || !Number.isFinite(value) || value <= 0) {
90
- return { error: `Invalid --max-spread "${spread}". Use a percent of the midpoint above 0, such as 5.` };
222
+ const text = spread.trim().replace(/%$/, "");
223
+ const value = Number(text);
224
+ if (!text || !Number.isFinite(value) || value <= 0) {
225
+ return { error: numberErrorMessage(`Invalid --max-spread "${spread}". Use a percent of the midpoint above 0, such as 5.`, text) };
91
226
  }
92
227
  criteria.maxSpreadPercent = value;
93
228
  }
94
229
  const openInterest = raw["--min-oi"];
95
230
  if (openInterest != null) {
96
- const value = Number(openInterest.trim());
97
- if (!/^\d+$/.test(openInterest.trim()) || !Number.isSafeInteger(value)) {
98
- return { error: `Invalid --min-oi "${openInterest}". Use a whole number of contracts, such as 100.` };
99
- }
231
+ const value = parseWholeNumber("--min-oi", openInterest, "contracts", 100);
232
+ if (typeof value !== "number") return value;
100
233
  criteria.minOpenInterest = value;
101
234
  }
235
+ const window = parseLeapsWindow(raw, now);
236
+ if ("error" in window) return window;
237
+ Object.assign(criteria, window);
238
+ const columns = raw["--columns"];
239
+ if (columns != null) {
240
+ const names = columns.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
241
+ const unknown = names.find((name) => !(LEAPS_EXTRA_COLUMNS as readonly string[]).includes(name));
242
+ if (names.length === 0 || unknown != null) {
243
+ return { error: `${unknown == null ? "--columns needs a name" : `Unknown --columns name "${unknown}"`}. `
244
+ + `Use any of ${LEAPS_EXTRA_COLUMNS.join(", ")}, comma-separated, such as --columns iv,cost,breakeven.` };
245
+ }
246
+ criteria.columns = LEAPS_EXTRA_COLUMNS.filter((name) => names.includes(name));
247
+ }
102
248
  const sort = raw["--sort"]?.trim().toLowerCase();
103
249
  if (sort != null) {
104
250
  if (!LEAPS_SORTS.includes(sort as LeapsSort)) return { error: `Invalid --sort "${raw["--sort"]}". Use one of ${LEAPS_SORTS.join(", ")}.` };
@@ -132,27 +278,50 @@ export type LeapsRow = {
132
278
  asOf: string | null;
133
279
  dataSource: "live" | "delayed" | null;
134
280
  delayMinutes: number | null;
281
+ // The fields below are on a row only when `--columns` asked for them.
282
+ /** `iv`: the implied volatility the contract's delta is valued at, percent a year. */
283
+ ivPercent?: number | null;
284
+ /** `ivr`: the underlying's 30-day IV rank against its 52 weeks, 0 to 100, and the close it ranks. */
285
+ ivRank?: number | null;
286
+ ivRankAsOf?: string | null;
287
+ /** `cost`: the midpoint times 100 shares, in the contract's currency. */
288
+ costPerContract?: number;
289
+ /** `breakeven`: the underlying's price at expiry that pays the midpoint back, and its distance from spot in percent. */
290
+ breakeven?: number;
291
+ breakevenFromSpotPercent?: number;
292
+ /** `next-earnings`: the underlying's next report date, YYYY-MM-DD. */
293
+ nextEarnings?: string | null;
294
+ /** `ex-dividend`: the underlying's latest ex-dividend date, YYYY-MM-DD. */
295
+ exDividend?: string | null;
296
+ /** `carry-net`: extrinsic per year net of the rate on the strike and the dividends, percent of spot (`optionCarryNet`). */
297
+ carryNetPerYearPercent?: number | null;
135
298
  };
136
299
 
137
300
  const round = (value: number, digits: number) => Number(value.toFixed(digits));
138
301
 
139
- function utcDate(seconds: number): string {
140
- const date = new Date(seconds * 1000);
141
- return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
302
+ /** Whether an expiry is still ahead and inside the window: its Days figure and its date both. */
303
+ function inLeapsWindow(expiration: number, window: LeapsWindow, now: number): boolean {
304
+ const remaining = daysToExpiryFrom(expiration, now);
305
+ if (!(remaining > 0)) return false;
306
+ const days = Math.floor(remaining);
307
+ const date = expiryIsoDate(expiration);
308
+ return (window.minDays == null || days >= window.minDays) && (window.maxDays == null || days <= window.maxDays)
309
+ && (window.expiryFrom == null || date >= window.expiryFrom) && (window.expiryTo == null || date <= window.expiryTo);
142
310
  }
143
311
 
144
- /** The listed expiries more than a year out, nearest first. */
145
- function leapsExpirations(expirations: readonly number[], now: number): number[] {
312
+ /** The listed expiries inside the window, nearest first. */
313
+ function leapsExpirations(expirations: readonly number[], window: LeapsWindow, now: number): number[] {
146
314
  return [...new Set(expirations)]
147
- .filter((expiration) => daysToExpiryFrom(expiration, now) > LEAPS_MIN_DAYS)
315
+ .filter((expiration) => inLeapsWindow(expiration, window, now))
148
316
  .sort((left, right) => left - right);
149
317
  }
150
318
 
151
319
  /**
152
- * The contracts of one chain that pass the screen: one side, more than a year
153
- * out, delta in the band, a two-sided quote no wider than the cap, enough open
154
- * interest, and a carry that can be read. `chain` carries modeled deltas
155
- * (`chainWithModelFigures`).
320
+ * The contracts of one chain that pass the screen: one side, inside the
321
+ * window, delta in the band, a two-sided quote no wider than the cap, enough
322
+ * open interest, and a carry that can be read. `chain` carries modeled IV and
323
+ * deltas (`chainWithModelFigures`). The `--columns` figures that are the
324
+ * contract's own are added here; the underlying's are added per symbol.
156
325
  */
157
326
  function screenLeapsChain(
158
327
  symbol: string,
@@ -162,13 +331,14 @@ function screenLeapsChain(
162
331
  now: number,
163
332
  ): { rows: LeapsRow[]; scanned: number } {
164
333
  const spot = inputs.spot;
165
- const contracts = (criteria.side === "call" ? chain.calls : chain.puts) as Array<OptionContract & { delta?: number | null }>;
334
+ const contracts = (criteria.side === "call" ? chain.calls : chain.puts) as Array<OptionContract & { delta?: number | null; iv?: number | null }>;
166
335
  const valuationTime = optionQuoteValuationTime(chain, now);
336
+ const wants = new Set(criteria.columns);
167
337
  const rows: LeapsRow[] = [];
168
338
  let scanned = 0;
169
339
  for (const contract of contracts) {
340
+ if (!inLeapsWindow(contract.expiration, criteria, now)) continue;
170
341
  const days = daysToExpiryFrom(contract.expiration, now);
171
- if (!(days > LEAPS_MIN_DAYS)) continue;
172
342
  scanned += 1;
173
343
  if (spot == null || contract.delta == null || !Number.isFinite(contract.delta)) continue;
174
344
  const delta = Math.abs(contract.delta);
@@ -179,9 +349,9 @@ function screenLeapsChain(
179
349
  if (openInterest == null || !Number.isFinite(openInterest) || openInterest < criteria.minOpenInterest) continue;
180
350
  const carry = optionCarry(contract, criteria.side, spot, valuationTime);
181
351
  if (!carry || carry.extrinsicPerYear == null) continue;
182
- rows.push({
352
+ const row: LeapsRow = {
183
353
  symbol,
184
- expiration: utcDate(contract.expiration),
354
+ expiration: expiryIsoDate(contract.expiration),
185
355
  days: Math.floor(days),
186
356
  side: criteria.side,
187
357
  contract: contract.contractSymbol,
@@ -198,7 +368,22 @@ function screenLeapsChain(
198
368
  asOf: chain.asOf ?? null,
199
369
  dataSource: chain.dataSource ?? null,
200
370
  delayMinutes: chain.delayMinutes ?? null,
201
- });
371
+ };
372
+ if (wants.has("iv")) row.ivPercent = contract.iv != null && Number.isFinite(contract.iv) ? round(contract.iv * 100, 2) : null;
373
+ // A two-sided quote passed the spread cap, so the midpoint is there.
374
+ const mid = optionMid(contract)!;
375
+ if (wants.has("cost")) row.costPerContract = round(mid * SHARES_PER_CONTRACT, 2);
376
+ if (wants.has("breakeven")) {
377
+ const breakeven = optionBreakeven(contract, criteria.side)!;
378
+ row.breakeven = round(breakeven, 4);
379
+ row.breakevenFromSpotPercent = round((breakeven / spot - 1) * 100, 3);
380
+ }
381
+ if (wants.has("carry-net")) {
382
+ // An unknown dividend yield counts as none, as the delta beside it is valued.
383
+ const net = optionCarryNet(carry, criteria.side, contract.strike, spot, OPTION_MODEL_RATE, inputs.dividendYield ?? 0);
384
+ row.carryNetPerYearPercent = net == null ? null : round(net * 100, 3);
385
+ }
386
+ rows.push(row);
202
387
  }
203
388
  return { rows, scanned };
204
389
  }
@@ -228,20 +413,50 @@ const SORT_LABELS: Record<LeapsSort, string> = {
228
413
  symbol: "symbol",
229
414
  };
230
415
 
416
+ function isDefaultWindow(window: LeapsWindow): boolean {
417
+ return window.minDays === DEFAULT_MIN_DAYS && window.maxDays == null && window.expiryFrom == null && window.expiryTo == null;
418
+ }
419
+
420
+ /** The window in words: `more than a year out`, `365 to 730 days out`, `180 days or more out, expiring by 2027-12-31`. */
421
+ function describeWindow(window: LeapsWindow): string {
422
+ if (isDefaultWindow(window)) return "more than a year out";
423
+ const { maxDays, expiryFrom: from, expiryTo: to } = window;
424
+ const minDays = window.minDays || null;
425
+ const days = minDays != null && maxDays != null ? `${minDays} to ${maxDays} days out`
426
+ : minDays != null ? `${minDays} days or more out`
427
+ : maxDays != null ? `${maxDays} days out at most` : null;
428
+ const dates = from != null && to != null ? (from === to ? `expiring ${from}` : `expiring ${from} to ${to}`)
429
+ : from != null ? `expiring ${from} or later`
430
+ : to != null ? `expiring by ${to}` : null;
431
+ return [days, dates].filter((part): part is string => part != null).join(", ");
432
+ }
433
+
231
434
  /** The criteria in one line above the table. */
232
435
  function describeLeapsCriteria(criteria: LeapsCriteria): string {
233
436
  const side = criteria.side === "call" ? "Calls" : "Puts";
234
- return `${side} more than a year out, delta ${criteria.minDelta.toFixed(2)} to ${criteria.maxDelta.toFixed(2)}, `
437
+ return `${side} ${describeWindow(criteria)}, delta ${criteria.minDelta.toFixed(2)} to ${criteria.maxDelta.toFixed(2)}, `
235
438
  + `spread at most ${criteria.maxSpreadPercent}%, open interest at least ${criteria.minOpenInterest}; `
236
439
  + `sorted by ${SORT_LABELS[criteria.sort]}.`;
237
440
  }
238
441
 
239
- interface LeapsSymbolResult {
442
+ /** What `--columns` reads about an underlying rather than a contract. */
443
+ interface LeapsUnderlying {
444
+ ivRank?: number | null;
445
+ ivRankAsOf?: string | null;
446
+ nextEarnings?: string | null;
447
+ exDividend?: string | null;
448
+ /** The dividend yield carry-net counts; null when unknown, which counts as none. */
449
+ dividendYield?: number | null;
450
+ }
451
+
452
+ interface LeapsSymbolResult extends LeapsUnderlying {
240
453
  symbol: string;
241
454
  spot: number | null;
242
- /** The LEAPS expiries read, YYYY-MM-DD. */
455
+ /** The expiries read, YYYY-MM-DD. */
243
456
  expirations: string[];
244
- /** Contracts on the screened side more than a year out. */
457
+ /** Expiries inside the window past the per-symbol cap, not read. */
458
+ skippedExpirations?: string[];
459
+ /** Contracts on the screened side inside the window. */
245
460
  scanned: number;
246
461
  matched: number;
247
462
  /** Why the symbol has no rows, when it is not just that none passed. */
@@ -273,17 +488,101 @@ async function loadChain(market: MarketContext, listing: CliListing, expiration:
273
488
  });
274
489
  }
275
490
 
491
+ /** IV rank for every symbol in one request, as VCA reads it; a failed request is one reason for all. */
492
+ type LeapsIvRanks = { rows: Map<string, IvScreenRow>; error: string | null };
493
+
494
+ /** The bare US symbol IV history is keyed by; null for a listing abroad, which has none. */
495
+ function ivKey(listing: CliListing): string | null {
496
+ try {
497
+ return ivSymbol(listing.key);
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
502
+
503
+ /** Why a symbol IV history covers has no rank yet, by where its history stands. */
504
+ const IV_COVERAGE_REASONS: Partial<Record<string, string>> = {
505
+ queued: "IV history queued, try again in a few minutes",
506
+ backfilling: "IV history still loading",
507
+ unavailable: "no options history",
508
+ };
509
+
510
+ async function loadLeapsIvRanks(listings: readonly CliListing[]): Promise<LeapsIvRanks> {
511
+ const symbols = listings.flatMap((listing) => ivKey(listing) ?? []);
512
+ if (symbols.length === 0) return { rows: new Map(), error: null };
513
+ try {
514
+ const payload = await loadIvScreen(symbols);
515
+ return { rows: new Map(payload.rows.map((row) => [row.symbol.toUpperCase(), row])), error: null };
516
+ } catch (error) {
517
+ return { rows: new Map(), error: errorMessage(error) ?? "no response" };
518
+ }
519
+ }
520
+
521
+ /**
522
+ * The underlying's `--columns` figures and, in one phrase, what of them it
523
+ * lacks: IV rank from Cloud IV history; the next report and the latest
524
+ * ex-dividend date from the financials DES reads, which also give the dividend
525
+ * yield delta is valued at. A fund reports no earnings, and a stock paying no
526
+ * dividend has no ex-date, so neither is named as missing.
527
+ */
528
+ function underlyingFigures(
529
+ listing: CliListing,
530
+ columns: ReadonlySet<LeapsExtraColumn>,
531
+ financials: TickerFinancials | null,
532
+ instrumentType: string | undefined,
533
+ dividendYield: number | undefined,
534
+ ivRanks: LeapsIvRanks | null,
535
+ now: number,
536
+ ): { figures: LeapsUnderlying; missing: string[] } {
537
+ const figures: LeapsUnderlying = {};
538
+ const missing: string[] = [];
539
+ if (columns.has("ivr")) {
540
+ const key = ivKey(listing);
541
+ const row = key == null ? undefined : ivRanks?.rows.get(key);
542
+ const rank = row?.iv30?.rank;
543
+ figures.ivRank = rank != null && Number.isFinite(rank) ? round(rank, 1) : null;
544
+ figures.ivRankAsOf = figures.ivRank != null ? row?.iv30?.date ?? null : null;
545
+ // A failed request is said once for every symbol.
546
+ if (figures.ivRank == null && !ivRanks?.error) {
547
+ const reason = key == null ? "US listings only"
548
+ : row?.iv30?.rankNote ?? row?.warnings?.[0] ?? (row ? IV_COVERAGE_REASONS[row.coverageStatus ?? row.status] ?? null : "no IV history");
549
+ missing.push(`no IV rank${reason ? ` (${reason})` : ""}`);
550
+ }
551
+ }
552
+ const today = zonedDateKey(now, "America/New_York");
553
+ if (columns.has("next-earnings")) {
554
+ const next = financials?.nextEarnings?.date;
555
+ figures.nextEarnings = next && ISO_DATE.test(next) && next >= today ? next : null;
556
+ }
557
+ if (columns.has("ex-dividend")) {
558
+ const exDate = financials?.fundamentals?.exDividendDate;
559
+ figures.exDividend = exDate && ISO_DATE.test(exDate) ? exDate : null;
560
+ }
561
+ if (columns.has("carry-net")) figures.dividendYield = dividendYield ?? null;
562
+ if (financials == null && (columns.has("next-earnings") || columns.has("ex-dividend") || columns.has("carry-net"))) {
563
+ missing.push(columns.has("carry-net") ? "no earnings or dividend data, so carry-net counts no dividends" : "no earnings or dividend data");
564
+ return { figures, missing };
565
+ }
566
+ if (columns.has("next-earnings") && figures.nextEarnings == null && !isFundType(instrumentType)) missing.push("no upcoming earnings date");
567
+ if (columns.has("ex-dividend") && figures.exDividend == null && dividendYield !== 0) missing.push("no ex-dividend date");
568
+ if (columns.has("carry-net") && dividendYield == null) missing.push("dividend yield unknown, so carry-net counts no dividends");
569
+ return { figures, missing };
570
+ }
571
+
276
572
  async function screenSymbol(
277
573
  market: MarketContext,
278
574
  listing: CliListing,
279
575
  criteria: LeapsCriteria,
280
576
  refresh: boolean,
281
577
  limit: ReturnType<typeof concurrencyLimit>,
578
+ ivRanks: Promise<LeapsIvRanks> | null,
282
579
  now: number,
283
580
  ): Promise<{ result: LeapsSymbolResult; rows: LeapsRow[]; warning: string | null }> {
284
581
  const symbol = listing.key;
285
582
  const quote = loadListingQuote(market.dataProvider, listing);
286
- const inputsPromise = loadOptionModelInputs(market.dataProvider, listing, quote, refresh);
583
+ // One read of the financials gives delta its dividend yield and --columns the report and ex-dividend dates.
584
+ const financials = loadUnderlyingFinancials(market.dataProvider, listing, refresh);
585
+ const inputsPromise = loadOptionModelInputs(market.dataProvider, listing, quote, refresh, financials);
287
586
  const fail = (error: string, spot: number | null = null) => ({
288
587
  result: { symbol, spot, expirations: [], scanned: 0, matched: 0, error },
289
588
  rows: [],
@@ -297,13 +596,19 @@ async function screenSymbol(
297
596
  }
298
597
  const inputs = await inputsPromise;
299
598
  const spot = inputs.spot ?? null;
300
- const listed = leapsExpirations(catalogue.expirationDates, now);
599
+ const listed = leapsExpirations(catalogue.expirationDates, criteria, now);
301
600
  if (listed.length === 0) {
302
- const last = [...catalogue.expirationDates].sort((left, right) => left - right).at(-1);
303
- return fail(last == null ? "no listed options" : `no expiry more than a year out (last ${utcDate(last)})`, spot);
601
+ const all = [...catalogue.expirationDates].sort((left, right) => left - right);
602
+ const last = all.at(-1);
603
+ if (last == null) return fail("no listed options", spot);
604
+ return fail(isDefaultWindow(criteria)
605
+ ? `no expiry more than a year out (last ${expiryIsoDate(last)})`
606
+ : `no expiry in the window, ${describeWindow(criteria)} (listed ${expiryIsoDate(all[0]!)} to ${expiryIsoDate(last)})`, spot);
304
607
  }
305
608
  if (spot == null) return fail("no current quote to value delta and carry", null);
609
+ // A window holding more expiries than one run reads keeps those nearest its start, and says which it left.
306
610
  const expirations = listed.slice(0, MAX_LEAPS_EXPIRIES);
611
+ const skipped = listed.slice(MAX_LEAPS_EXPIRIES).map(expiryIsoDate);
307
612
  const chains = await Promise.all(expirations.map((expiration) => limit(() => loadChain(market, listing, expiration, refresh))
308
613
  .then((chain) => ({ chain }), (error: unknown) => ({ error: errorMessage(error) ?? "no response", expiration }))));
309
614
  const rows: LeapsRow[] = [];
@@ -311,20 +616,36 @@ async function screenSymbol(
311
616
  const missed: string[] = [];
312
617
  for (const loaded of chains) {
313
618
  if ("error" in loaded) {
314
- missed.push(utcDate(loaded.expiration));
619
+ missed.push(expiryIsoDate(loaded.expiration));
315
620
  continue;
316
621
  }
317
622
  const screened = screenLeapsChain(symbol, chainWithModelFigures(loaded.chain, { ...inputs, now }), criteria, inputs, now);
318
623
  rows.push(...screened.rows);
319
624
  scanned += screened.scanned;
320
625
  }
626
+ if (missed.length === expirations.length) return fail(`options chain unavailable for ${missed.join(", ")}`, spot);
627
+ const columns = new Set(criteria.columns);
628
+ const underlying: { figures: LeapsUnderlying; missing: string[] } = columns.size === 0
629
+ ? { figures: {}, missing: [] }
630
+ : underlyingFigures(listing, columns, await financials, inputs.quote?.instrumentType ?? (await quote)?.instrumentType,
631
+ inputs.dividendYield, ivRanks ? await ivRanks : null, now);
632
+ // Each row carries the underlying's figures; the dividend yield behind carry-net is in `symbols`.
633
+ const rowFigures: LeapsUnderlying = { ...underlying.figures };
634
+ delete rowFigures.dividendYield;
635
+ for (const row of rows) Object.assign(row, rowFigures);
321
636
  const notes = [
322
637
  missed.length > 0 ? `chain unavailable for ${missed.join(", ")}` : null,
323
- listed.length > expirations.length ? `read the nearest ${expirations.length} of ${listed.length} LEAPS expiries` : null,
638
+ skipped.length > 0
639
+ ? `read the ${expirations.length} expiries nearest the window's start, through ${expiryIsoDate(expirations.at(-1)!)}; `
640
+ + `${skipped.length} later not read (${skipped.join(", ")}). Raise --min-days or --expiry-from to reach them`
641
+ : null,
642
+ ...underlying.missing,
324
643
  ].filter((note): note is string => note != null);
325
- if (missed.length === expirations.length) return fail(`options chain unavailable for ${missed.join(", ")}`, spot);
326
644
  return {
327
- result: { symbol, spot, expirations: expirations.map(utcDate), scanned, matched: rows.length },
645
+ result: {
646
+ symbol, spot, expirations: expirations.map(expiryIsoDate), ...(skipped.length > 0 ? { skippedExpirations: skipped } : {}),
647
+ scanned, matched: rows.length, ...underlying.figures,
648
+ },
328
649
  rows,
329
650
  warning: notes.length > 0 ? `${symbol}: ${notes.join("; ")}` : null,
330
651
  };
@@ -333,6 +654,10 @@ async function screenSymbol(
333
654
  const formatDelta = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? value.toFixed(3) : "—";
334
655
  const formatPrice = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "—";
335
656
  const formatPercent = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(1)}%` : "—";
657
+ const formatSignedPercent = (value: unknown) => typeof value === "number" && Number.isFinite(value)
658
+ ? `${value > 0 ? "+" : ""}${value.toFixed(1)}%` : "—";
659
+ const formatWhole = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? formatCountCell(Math.round(value)) : "—";
660
+ const formatDate = (value: unknown) => typeof value === "string" && value ? value : "—";
336
661
 
337
662
  /** Every exported column; the text table keeps those that fit 100 columns. */
338
663
  const LEAPS_COLUMNS: CliResultColumn<LeapsRow>[] = [
@@ -356,6 +681,20 @@ const LEAPS_COLUMNS: CliResultColumn<LeapsRow>[] = [
356
681
  { key: "delayMinutes", header: "Delay Min", align: "right" },
357
682
  ];
358
683
 
684
+ /** What each `--columns` name adds to the exports, after Extrinsic/yr %. */
685
+ const EXTRA_EXPORT_COLUMNS: Record<LeapsExtraColumn, CliResultColumn<LeapsRow>[]> = {
686
+ iv: [{ key: "ivPercent", header: "IV %", align: "right", format: formatPercent }],
687
+ ivr: [{ key: "ivRank", header: "IV Rank", align: "right" }, { key: "ivRankAsOf", header: "IV Rank As Of" }],
688
+ cost: [{ key: "costPerContract", header: "Cost/Contract (mid x 100)", align: "right" }],
689
+ breakeven: [
690
+ { key: "breakeven", header: "Breakeven (at mid)", align: "right" },
691
+ { key: "breakevenFromSpotPercent", header: "Breakeven vs Spot %", align: "right" },
692
+ ],
693
+ "next-earnings": [{ key: "nextEarnings", header: "Next Earnings" }],
694
+ "ex-dividend": [{ key: "exDividend", header: "Ex-Dividend" }],
695
+ "carry-net": [{ key: "carryNetPerYearPercent", header: "Carry Net/yr %", align: "right" }],
696
+ };
697
+
359
698
  const TEXT_COLUMN_KEYS = ["symbol", "expiration", "days", "strike", "delta", "bid", "ask", "spreadPercent", "openInterest", "extrinsic", "extrinsicPerYearPercent"];
360
699
  const LEAPS_TEXT_COLUMNS: CliResultColumn<LeapsRow>[] = TEXT_COLUMN_KEYS.map((key) => {
361
700
  const column = LEAPS_COLUMNS.find((candidate) => candidate.key === key)!;
@@ -365,6 +704,59 @@ const LEAPS_TEXT_COLUMNS: CliResultColumn<LeapsRow>[] = TEXT_COLUMN_KEYS.map((ke
365
704
  return column;
366
705
  });
367
706
 
707
+ /** The text table's columns for a contract's own `--columns` figures; the underlying's go in a table of their own above. */
708
+ const EXTRA_TEXT_COLUMNS: Partial<Record<LeapsExtraColumn, CliResultColumn<LeapsRow>[]>> = {
709
+ iv: [{ key: "ivPercent", header: "IV", align: "right", format: formatPercent }],
710
+ "carry-net": [{ key: "carryNetPerYearPercent", header: "Net/yr", align: "right", format: formatPercent }],
711
+ cost: [{ key: "costPerContract", header: "Cost", align: "right", format: formatWhole }],
712
+ breakeven: [
713
+ // The distance from spot compares across symbols; the price goes first when the table is narrow.
714
+ { key: "breakeven", header: "B/E", align: "right", format: formatPrice, optional: true, dropPriority: 3 },
715
+ { key: "breakevenFromSpotPercent", header: "B/E%", align: "right", format: formatSignedPercent },
716
+ ],
717
+ };
718
+
719
+ function exportColumns(columns: readonly LeapsExtraColumn[]): CliResultColumn<LeapsRow>[] {
720
+ const extras = columns.flatMap((name) => EXTRA_EXPORT_COLUMNS[name]);
721
+ const at = LEAPS_COLUMNS.findIndex((column) => column.key === "extrinsicPerYearPercent") + 1;
722
+ return [...LEAPS_COLUMNS.slice(0, at), ...extras, ...LEAPS_COLUMNS.slice(at)];
723
+ }
724
+
725
+ /**
726
+ * The text table with the contract's own `--columns` figures: IV beside delta,
727
+ * the rest after Extr/yr. To stay inside 100 columns the break-even price goes
728
+ * first, then the quote's bid and ask, then extrinsic per share; the exports
729
+ * keep every one.
730
+ */
731
+ function textColumns(columns: readonly LeapsExtraColumn[]): CliResultColumn<LeapsRow>[] {
732
+ // Carry net of financing sits beside the carry it adjusts.
733
+ const extras = (["carry-net", "cost", "breakeven"] as const).filter((name) => columns.includes(name))
734
+ .flatMap((name) => EXTRA_TEXT_COLUMNS[name] ?? []);
735
+ if (extras.length === 0 && !columns.includes("iv")) return LEAPS_TEXT_COLUMNS;
736
+ return LEAPS_TEXT_COLUMNS.flatMap((column) => {
737
+ if (column.key === "bid" || column.key === "ask") return [{ ...column, optional: true, dropPriority: 2 }];
738
+ if (column.key === "extrinsic") return [{ ...column, optional: true, dropPriority: 1 }];
739
+ if (column.key === "delta") return [column, ...columns.includes("iv") ? EXTRA_TEXT_COLUMNS.iv! : []];
740
+ if (column.key === "extrinsicPerYearPercent") return [column, ...extras];
741
+ return [column];
742
+ });
743
+ }
744
+
745
+ /** The underlying's `--columns` figures, one row per symbol, above the contracts in text. */
746
+ function underlyingTable(symbols: readonly LeapsSymbolResult[], columns: readonly LeapsExtraColumn[]): string {
747
+ const shown: Array<{ column: CliTableColumn; cell: (entry: LeapsSymbolResult) => string }> = [
748
+ ...columns.includes("ivr") ? [{ column: { header: "IVR", align: "right" as const }, cell: (entry: LeapsSymbolResult) => formatWhole(entry.ivRank) }] : [],
749
+ ...columns.includes("next-earnings") ? [{ column: { header: "Next earnings" }, cell: (entry: LeapsSymbolResult) => formatDate(entry.nextEarnings) }] : [],
750
+ ...columns.includes("ex-dividend") ? [{ column: { header: "Ex-dividend" }, cell: (entry: LeapsSymbolResult) => formatDate(entry.exDividend) }] : [],
751
+ ];
752
+ const listed = symbols.filter((entry) => !entry.error);
753
+ if (shown.length === 0 || listed.length === 0) return "";
754
+ return renderTable(
755
+ [{ header: "Symbol", shrink: false }, ...shown.map((entry) => entry.column)],
756
+ listed.map((entry) => [entry.symbol, ...shown.map((column) => column.cell(entry))]),
757
+ );
758
+ }
759
+
368
760
  export async function runLeapsScreen(
369
761
  rawSymbols: readonly string[],
370
762
  exchangeOption: string | undefined,
@@ -381,33 +773,40 @@ export async function runLeapsScreen(
381
773
  )));
382
774
  const refresh = ctx.cliOptions.refresh;
383
775
  const limit = concurrencyLimit(LEAPS_CHAIN_CONCURRENCY);
384
- const screened = await Promise.all(listings.map((listing) => screenSymbol(market, listing, criteria, refresh, limit, now)));
776
+ // IV rank is one request for every symbol, read beside the chains and only when asked for.
777
+ const ivRanks = criteria.columns.includes("ivr") ? loadLeapsIvRanks(listings) : null;
778
+ const screened = await Promise.all(listings.map((listing) => screenSymbol(market, listing, criteria, refresh, limit, ivRanks, now)));
385
779
  const symbols = screened.map((entry) => entry.result);
386
780
  // Nothing to rank only when no symbol's chain loaded at all; a symbol without LEAPS is an answer.
387
781
  if (symbols.every((entry) => entry.error?.startsWith("options chain unavailable"))) {
388
782
  ctx.fail(screened.map((entry) => entry.warning).filter(Boolean).join("\n"));
389
783
  }
390
784
  const rows = sortLeapsRows(screened.flatMap((entry) => entry.rows), criteria.sort);
785
+ const ivRankError = ivRanks ? (await ivRanks).error : null;
391
786
  const warnings = [
392
787
  ...listings.flatMap(listingReadNotes),
393
788
  ...screened.map((entry) => entry.warning).filter((warning): warning is string => warning != null),
789
+ ...ivRankError ? [`IV rank unavailable (${ivRankError})`] : [],
394
790
  ];
395
791
  const { format, limit: rowLimit, tail } = ctx.cliOptions;
396
792
  const capped = format === "text" && rowLimit == null && tail == null && rows.length > LEAPS_TEXT_ROWS;
397
793
  const describe = (data: readonly LeapsRow[]) => {
398
794
  const criteriaLine = describeLeapsCriteria(criteria);
795
+ const underlying = underlyingTable(symbols, criteria.columns);
796
+ const head = underlying ? `${criteriaLine}\n\n${underlying}` : criteriaLine;
399
797
  if (data.length === 0) {
400
798
  // With no rows the summary stands in for the empty message, so it carries both.
401
- return `${criteriaLine}\n\n${cliStyles.muted("No contract passed the screen. Widen --delta, --max-spread or --min-oi.")}`;
799
+ const widen = isDefaultWindow(criteria) ? "--delta, --max-spread or --min-oi" : "--delta, --max-spread, --min-oi or the expiry window";
800
+ return `${head}\n\n${cliStyles.muted(`No contract passed the screen. Widen ${widen}.`)}`;
402
801
  }
403
- return capped
404
- ? `${criteriaLine}\n${cliStyles.muted(`The best ${LEAPS_TEXT_ROWS} of ${data.length}; --limit ${data.length} shows them all, --csv and --json carry every row.`)}`
405
- : criteriaLine;
802
+ const cap = `The best ${LEAPS_TEXT_ROWS} of ${data.length}; --limit ${data.length} shows them all, --csv and --json carry every row.`;
803
+ if (!capped) return head;
804
+ return underlying ? `${criteriaLine}\n${cliStyles.muted(cap)}\n\n${underlying}` : `${criteriaLine}\n${cliStyles.muted(cap)}`;
406
805
  };
407
806
  ctx.printResult({
408
807
  data: rows,
409
808
  metadata: {
410
- criteria: { ...criteria, minDays: LEAPS_MIN_DAYS },
809
+ criteria,
411
810
  symbols,
412
811
  // What delta and carry are valued from; spot per symbol is on each row and in `symbols`.
413
812
  model: { rate: OPTION_MODEL_RATE },
@@ -416,8 +815,8 @@ export async function runLeapsScreen(
416
815
  freshness: rowsFreshness(rows),
417
816
  }, {
418
817
  rows: (data) => data,
419
- columns: LEAPS_COLUMNS,
420
- textColumns: LEAPS_TEXT_COLUMNS,
818
+ columns: exportColumns(criteria.columns),
819
+ textColumns: textColumns(criteria.columns),
421
820
  layout: "table",
422
821
  ...(capped ? { defaultLimit: LEAPS_TEXT_ROWS } : {}),
423
822
  summary: describe,