gloomberb 0.17.1 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/{plugins/builtin/cloud → account}/auth-dialog.tsx +7 -7
- package/src/api-client/earnings.ts +4 -2
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +18 -8
- package/src/api-client/types.ts +3 -1
- package/src/app/global-shortcuts.ts +10 -0
- package/src/app/keybindings/actions.ts +16 -0
- package/src/app/runtime/desktop-deeplink.ts +1 -1
- package/src/app.tsx +1 -1
- package/src/architecture/core-builtin-allowlist.json +4 -9
- package/src/cli/commands/market.ts +94 -25
- package/src/cli/commands/options-chain.ts +29 -33
- package/src/cli/commands/options-leaps.ts +462 -63
- package/src/cli/commands/search.ts +53 -9
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +43 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/catalog.ts +6 -3
- package/src/cli/pane-functions/dom.ts +56 -8
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +2 -2
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +65 -7
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/report-notices.ts +15 -0
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +60 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/command-bar/workflow/builtin.ts +2 -2
- package/src/components/command-bar/workflow/collection-actions.ts +1 -1
- package/src/components/feedback-dialog.tsx +1 -1
- package/src/components/layout/header.tsx +348 -102
- package/src/components/layout/shell/index.tsx +1 -1
- package/src/components/layout/status-bar-groups.ts +1 -1
- package/src/components/layout/status-bar.tsx +2 -2
- package/src/components/markdown-text.tsx +50 -64
- package/src/components/market-board/index.tsx +12 -6
- package/src/components/reader/article-reader.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +37 -12
- package/src/components/reader/reader-text.tsx +32 -17
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/layout-marketplace/gallery.tsx +2 -2
- package/src/layout-marketplace/linked-sync.ts +1 -1
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +28 -1
- package/src/market-data/published-apac-sessions.ts +12 -2
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chart-composer/index.tsx +1 -1
- package/src/plugins/builtin/chart-composer/presets.ts +33 -1
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/channel-labels.ts +2 -2
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/index.tsx +2 -2
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- package/src/plugins/builtin/chat/conversations.ts +1 -1
- package/src/plugins/builtin/chat/index.tsx +2 -2
- package/src/plugins/builtin/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/chat/share/flow.tsx +1 -1
- package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
- package/src/plugins/builtin/chat/sidebar.tsx +2 -2
- package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
- package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
- package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
- package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
- package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
- package/src/plugins/builtin/cloud/team/capability.ts +2 -2
- package/src/plugins/builtin/cloud/team/command.ts +4 -4
- package/src/plugins/builtin/cloud/team/module.tsx +3 -3
- package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
- package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
- package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
- package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
- package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
- package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
- package/src/plugins/builtin/cloud/team/views.ts +2 -2
- package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/detail.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +11 -2
- package/src/plugins/builtin/dividend-yield/pane.tsx +8 -5
- package/src/plugins/builtin/docs/blocks.tsx +13 -2
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +24 -6
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings/board-model.ts +18 -1
- package/src/plugins/builtin/earnings/board-table.ts +18 -3
- package/src/plugins/builtin/earnings/headless.ts +13 -2
- package/src/plugins/builtin/earnings/history-model.ts +1 -1
- package/src/plugins/builtin/earnings/history-table.ts +2 -2
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +32 -4
- package/src/plugins/builtin/futures/model.ts +65 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/notes/index.tsx +1 -1
- package/src/plugins/builtin/notes/owner.tsx +7 -6
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options/carry.ts +36 -0
- package/src/plugins/builtin/options/open-interest-as-of.ts +16 -5
- package/src/plugins/builtin/options/view.tsx +19 -7
- package/src/plugins/builtin/options-calculator/pane.tsx +58 -4
- package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
- package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
- package/src/plugins/builtin/research/event-model.ts +21 -4
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/series-inventory.ts +1 -0
- package/src/plugins/builtin/shared/earnings-report.ts +10 -4
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/styles.css +10 -2
- package/src/sources/gloomberb-cloud/normalizers.ts +3 -0
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
- package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
- package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
- package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
- package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
- package/src/time-series/resolve.ts +19 -2
- package/src/types/data-provider.ts +11 -0
- package/src/types/financials.ts +46 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- package/src/utils/decimal-comma.ts +18 -0
- package/src/utils/declared-dividends.ts +39 -0
- package/src/utils/exchanges.ts +7 -0
- package/src/utils/forward-dividend.ts +62 -9
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
- package/src/utils/option-expiry.ts +16 -0
- 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 {
|
|
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 {
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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>]
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
const
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
97
|
-
if (
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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) =>
|
|
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,
|
|
153
|
-
*
|
|
154
|
-
* interest, and a carry that can be read. `chain` carries modeled
|
|
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
|
-
|
|
352
|
+
const row: LeapsRow = {
|
|
183
353
|
symbol,
|
|
184
|
-
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}
|
|
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
|
-
|
|
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
|
|
455
|
+
/** The expiries read, YYYY-MM-DD. */
|
|
243
456
|
expirations: string[];
|
|
244
|
-
/**
|
|
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
|
-
|
|
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
|
|
303
|
-
|
|
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(
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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:
|
|
420
|
-
textColumns:
|
|
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,
|