gloomberb 0.18.2 → 0.18.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/patches/electrobun@1.18.1.patch +106 -5
- package/src/api-client/data.ts +15 -0
- package/src/api-client/index.ts +2 -0
- package/src/api-client/request.ts +5 -3
- package/src/api-client/telemetry.ts +17 -0
- package/src/app/global-shortcuts.ts +1 -16
- package/src/app/runtime/update.ts +28 -8
- package/src/architecture/pane-tabs-allowlist.json +65 -0
- package/src/cli/commands/market.ts +73 -33
- package/src/cli/commands/ticker.ts +82 -25
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +8 -0
- package/src/cli/issuer-membership.ts +103 -0
- package/src/cli/listing-arg.ts +15 -2
- package/src/cli/name-entries.ts +51 -12
- package/src/cli/pane-functions/capabilities.ts +2 -0
- package/src/cli/pane-functions/catalog.ts +16 -0
- package/src/cli/pane-functions/dom.ts +2 -1
- package/src/cli/pane-functions/headless.ts +25 -6
- package/src/cli/pane-functions/index.ts +64 -5
- package/src/cli/pane-functions/resolver.ts +19 -5
- package/src/cli/report-tables.ts +136 -18
- package/src/cli/result.ts +12 -4
- package/src/cli/symbol-list.ts +47 -11
- package/src/components/chart/composite/composite-chart.tsx +14 -5
- package/src/components/chart/composite/drawing-store.ts +26 -6
- package/src/components/chart/composite/interactions.ts +2 -1
- package/src/components/chart/composite/panel-surface.tsx +11 -0
- package/src/components/chart/composite/types.ts +6 -0
- package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
- package/src/components/command-bar/routes/root/command-items.ts +32 -6
- package/src/components/data-table/view.tsx +23 -3
- package/src/components/index.ts +1 -0
- package/src/components/layout/detached-pane-shell.tsx +1 -0
- package/src/components/layout/floating-pane.tsx +1 -0
- package/src/components/layout/header.tsx +228 -55
- package/src/components/layout/pane/header.tsx +10 -3
- package/src/components/layout/pane/index.tsx +1 -0
- package/src/components/layout/pane/pane-tabs.tsx +9 -1
- package/src/components/ui/stat-grid.tsx +28 -3
- package/src/components/ui/status.tsx +3 -1
- package/src/core/app-services.ts +4 -0
- package/src/i18n/es.ts +22 -0
- package/src/i18n/ja.ts +22 -0
- package/src/i18n/ko.ts +22 -0
- package/src/i18n/zh-cn.ts +22 -0
- package/src/i18n/zh-tw.ts +22 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
- package/src/plugins/builtin/alerts/events.ts +1 -0
- package/src/plugins/builtin/alerts/research-rules.ts +1 -0
- package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
- package/src/plugins/builtin/attention/pane.tsx +16 -14
- package/src/plugins/builtin/awards/pane.tsx +6 -6
- package/src/plugins/builtin/backtest/export.ts +1 -1
- package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
- package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
- package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
- package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
- package/src/plugins/builtin/catalysts/client.ts +2 -2
- package/src/plugins/builtin/catalysts/pane.tsx +3 -3
- package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
- package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
- package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
- package/src/plugins/builtin/company-attention/pane.tsx +13 -11
- package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
- package/src/plugins/builtin/correlation/headless.ts +2 -1
- package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
- package/src/plugins/builtin/correlation/settings.ts +1 -0
- package/src/plugins/builtin/cot/pane.tsx +4 -3
- package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
- package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
- package/src/plugins/builtin/doe/pane.tsx +1 -1
- package/src/plugins/builtin/equity-screener/headless.ts +10 -0
- package/src/plugins/builtin/equity-screener/model.ts +28 -0
- package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
- package/src/plugins/builtin/exposure/pane.tsx +10 -9
- package/src/plugins/builtin/gpu/board.tsx +4 -1
- package/src/plugins/builtin/gpu/changes.tsx +10 -10
- package/src/plugins/builtin/gpu/equities.tsx +4 -1
- package/src/plugins/builtin/gpu/history.tsx +4 -2
- package/src/plugins/builtin/gpu/pane.tsx +5 -3
- package/src/plugins/builtin/holders/client.ts +2 -1
- package/src/plugins/builtin/holders/headless.ts +2 -1
- package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
- package/src/plugins/builtin/insider/base-values.ts +11 -7
- package/src/plugins/builtin/insider/client.ts +3 -0
- package/src/plugins/builtin/insider/headless.ts +19 -6
- package/src/plugins/builtin/insider/index.tsx +17 -8
- package/src/plugins/builtin/insider/issuer.ts +34 -4
- package/src/plugins/builtin/insider/signals-client.ts +2 -2
- package/src/plugins/builtin/insider/table-model.ts +35 -13
- package/src/plugins/builtin/insider/table.tsx +5 -2
- package/src/plugins/builtin/iv-history/events.ts +145 -0
- package/src/plugins/builtin/iv-history/format.ts +3 -0
- package/src/plugins/builtin/iv-history/headless.ts +91 -7
- package/src/plugins/builtin/jodi/pane.tsx +4 -4
- package/src/plugins/builtin/market-movers/client.ts +4 -3
- package/src/plugins/builtin/market-movers/screener.ts +4 -3
- package/src/plugins/builtin/notes/cli.ts +1 -1
- package/src/plugins/builtin/notes/export.ts +17 -6
- package/src/plugins/builtin/notes/files.ts +16 -5
- package/src/plugins/builtin/options/enrichment-model.ts +10 -9
- package/src/plugins/builtin/options/model-spot.ts +70 -4
- package/src/plugins/builtin/options/view.tsx +41 -13
- package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
- package/src/plugins/builtin/options-positioning/headless.ts +11 -3
- package/src/plugins/builtin/options-positioning/model.ts +24 -0
- package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
- package/src/plugins/builtin/options-scenario/client.ts +19 -1
- package/src/plugins/builtin/options-scenario/headless.ts +13 -6
- package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/power/client.ts +2 -2
- package/src/plugins/builtin/power/pane.tsx +36 -31
- package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
- package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
- package/src/plugins/builtin/seasonality/client.ts +2 -1
- package/src/plugins/builtin/sectors/client.ts +4 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
- package/src/plugins/builtin/shared/research-trading.tsx +2 -0
- package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
- package/src/plugins/builtin/shared/volatility/options.ts +2 -1
- package/src/plugins/builtin/social-mentions/client.ts +2 -1
- package/src/plugins/builtin/vol-surface/client.ts +23 -3
- package/src/plugins/builtin/vol-surface/headless.ts +33 -14
- package/src/plugins/builtin/vol-surface/model.ts +30 -10
- package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
- package/src/plugins/builtin/vol-surface/stored.ts +34 -3
- package/src/plugins/builtin/vol-surface/tables.ts +14 -0
- package/src/renderers/dom/desktop/controls.tsx +1 -1
- package/src/renderers/dom/desktop/query-bar.tsx +2 -0
- package/src/renderers/dom/host/tabs.tsx +16 -5
- package/src/renderers/dom/host/top-surface.ts +127 -0
- package/src/renderers/dom/styles.css +39 -0
- package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
- package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
- package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
- package/src/renderers/electrobun/view/news-source.ts +4 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
- package/src/renderers/opentui/start.tsx +7 -0
- package/src/sources/gloomberb-cloud/index.ts +7 -0
- package/src/sources/provider-router/index.ts +16 -0
- package/src/telemetry/crash-reports.ts +4 -0
- package/src/telemetry/update-failures-node.ts +59 -0
- package/src/telemetry/update-failures.ts +307 -0
- package/src/tickers/company-name-listing.ts +9 -7
- package/src/tickers/company-name.ts +43 -0
- package/src/tickers/listing-fate.ts +30 -0
- package/src/types/data-provider.ts +6 -0
- package/src/types/headless.ts +7 -0
- package/src/types/issuer-listings.ts +28 -0
- package/src/updater/failure.ts +163 -0
- package/src/updater/index.ts +87 -23
- package/src/utils/currency-pair.ts +26 -0
- package/src/utils/sec.ts +68 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DataProvider } from "../../../types/data-provider";
|
|
2
2
|
import type { PricePoint, Quote } from "../../../types/financials";
|
|
3
|
+
import { logFailure } from "../../../utils/log-failure";
|
|
3
4
|
import type { SectorDef } from "./sector-data";
|
|
4
5
|
import { getCompletedRegularSessionDisplay, getRegularSessionDisplay } from "../../../market-data/market/status";
|
|
5
6
|
import {
|
|
@@ -26,7 +27,7 @@ export async function loadSectorRows(
|
|
|
26
27
|
if (provider.getQuotesBatch) {
|
|
27
28
|
const results = await provider.getQuotesBatch(
|
|
28
29
|
sectors.map((sector) => ({ symbol: sector.etf, exchange: "" })),
|
|
29
|
-
).catch((
|
|
30
|
+
).catch(logFailure("sectors", "Sector quote batch failed", []));
|
|
30
31
|
for (const result of results) quotes.set(result.target.symbol, result.quote ?? null);
|
|
31
32
|
}
|
|
32
33
|
// A partial batch must not silently replace a live quote with yesterday's
|
|
@@ -41,7 +42,7 @@ export async function loadSectorRows(
|
|
|
41
42
|
|
|
42
43
|
const histories = new Map(await Promise.all(sectors.map(async (sector) => [
|
|
43
44
|
sector.etf,
|
|
44
|
-
await provider.getPriceHistory(sector.etf, "", "1Y").catch((
|
|
45
|
+
await provider.getPriceHistory(sector.etf, "", "1Y").catch(logFailure("sectors", "Sector price history failed", [])),
|
|
45
46
|
] as const)));
|
|
46
47
|
// The quotes as sent carry any pre-market or after-hours print, for its own column.
|
|
47
48
|
const sentQuotes = new Map(quotes);
|
|
@@ -94,7 +95,7 @@ export async function loadSectorRows(
|
|
|
94
95
|
start.setUTCDate(start.getUTCDate() - 7);
|
|
95
96
|
const end = new Date(`${asOfDate}T00:00:00Z`);
|
|
96
97
|
end.setUTCDate(end.getUTCDate() + 1);
|
|
97
|
-
const extended = await provider.getDetailedPriceHistory(sector.etf, "", start, end, "1d").catch((
|
|
98
|
+
const extended = await provider.getDetailedPriceHistory(sector.etf, "", start, end, "1d").catch(logFailure("sectors", "Sector extended price history failed", []));
|
|
98
99
|
if (extended.length > 0) {
|
|
99
100
|
history = [...history, ...extended];
|
|
100
101
|
histories.set(sector.etf, history);
|
|
@@ -31,18 +31,31 @@ export function cachedCloudResource<T>(
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const isAbort = (error: unknown) => !!error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError";
|
|
35
|
+
|
|
34
36
|
/**
|
|
35
37
|
* Loads through the cache, falling back to the cached copy when a refresh
|
|
36
38
|
* fails. A refused session still throws, so the pane shows its sign-in wall
|
|
37
39
|
* instead of data the account can no longer see.
|
|
40
|
+
*
|
|
41
|
+
* Callers of one key share the request in flight, which runs on the first
|
|
42
|
+
* caller's `signal`. When that caller goes away (a tab left while it loads,
|
|
43
|
+
* for one that reads the same data), the shared request aborts; a caller
|
|
44
|
+
* whose own `signal` is still live asks again instead of showing that
|
|
45
|
+
* cancellation as its failure.
|
|
38
46
|
*/
|
|
39
47
|
export async function loadCloudResource<T>(
|
|
40
48
|
cache: CloudCache<T>,
|
|
41
49
|
key: string,
|
|
42
50
|
fetch: () => Promise<T>,
|
|
43
|
-
{ force = false, validate = (payload: T) => payload }: { force?: boolean; validate?: (payload: T) => T } = {},
|
|
51
|
+
{ force = false, validate = (payload: T) => payload, signal }: { force?: boolean; validate?: (payload: T) => T; signal?: AbortSignal } = {},
|
|
44
52
|
): Promise<CloudResource<T>> {
|
|
45
|
-
const
|
|
53
|
+
const cancelledByOther = (error: unknown) => !!signal && !signal.aborted && isAbort(error);
|
|
54
|
+
let result = await cache.load(key, fetch, { force }).catch((error: unknown) => {
|
|
55
|
+
if (cancelledByOther(error)) return null;
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
if (!result || cancelledByOther(result.error)) result = await cache.load(key, fetch, { force });
|
|
46
59
|
if (isAccessDenied(result.error)) throw result.error;
|
|
47
60
|
return { payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null };
|
|
48
61
|
}
|
|
@@ -25,6 +25,8 @@ export interface ResearchTrading extends ResearchTradeProjection {
|
|
|
25
25
|
/** Opens the ticket; without a side it keeps its draft, and Buy or Sell is chosen in it. */
|
|
26
26
|
open(side?: "BUY" | "SELL"): void;
|
|
27
27
|
close(): void;
|
|
28
|
+
/** A press on the chart takes the keyboard back from the ticket, even a field typing in it. */
|
|
29
|
+
focusChart?(): void;
|
|
28
30
|
/** This ticker's working orders and latest fills, for Overview; absent without a trading profile. */
|
|
29
31
|
orders?(width: number): ReactNode;
|
|
30
32
|
publish(projection: ResearchTradeProjection): void;
|
|
@@ -141,8 +141,9 @@ export function SeriesListDetail<Row, Column extends DataTableColumn, R extends
|
|
|
141
141
|
isNavigable,
|
|
142
142
|
renderSectionHeader,
|
|
143
143
|
emptyStateTitle,
|
|
144
|
+
status,
|
|
144
145
|
renderDetail,
|
|
145
|
-
}: Pick<DataTableViewProps<Row, Column>, "renderCell" | "isNavigable" | "renderSectionHeader" | "emptyStateTitle"> & {
|
|
146
|
+
}: Pick<DataTableViewProps<Row, Column>, "renderCell" | "isNavigable" | "renderSectionHeader" | "emptyStateTitle" | "status"> & {
|
|
146
147
|
list: ReturnType<typeof useSeriesList<unknown, R>>;
|
|
147
148
|
width: number;
|
|
148
149
|
height: number;
|
|
@@ -206,6 +207,7 @@ export function SeriesListDetail<Row, Column extends DataTableColumn, R extends
|
|
|
206
207
|
renderCell={renderCell}
|
|
207
208
|
renderSectionHeader={renderSectionHeader}
|
|
208
209
|
emptyStateTitle={emptyStateTitle}
|
|
210
|
+
status={status}
|
|
209
211
|
/>
|
|
210
212
|
</Box>
|
|
211
213
|
</Box>
|
|
@@ -192,10 +192,11 @@ export function expectedMove(
|
|
|
192
192
|
sigmaPercent: sigma != null && Number.isFinite(sigma) ? sigma / spot * 100 : null, strike: atm?.strike ?? null };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
/** 25-delta and 90/110 skew in decimal annualized IV; OVDV reads them on each expiry's fitted smile. */
|
|
195
|
+
/** 25-delta and 90/110 skew in decimal annualized IV; OVDV reads them on each expiry's fitted smile, from its forward. */
|
|
196
196
|
export interface SkewMetrics {
|
|
197
197
|
put25: number | null;
|
|
198
198
|
call25: number | null;
|
|
199
|
+
/** IV at the forward (ATMF): the butterfly and the term slope are measured from it. */
|
|
199
200
|
atm: number | null;
|
|
200
201
|
putCallSkew: number | null;
|
|
201
202
|
riskReversal: number | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unavailableText } from "../../../components/ui/status";
|
|
1
2
|
import { apiClient } from "../../../api-client";
|
|
2
3
|
import { ApiRequestError, isAccessDenied } from "../../../api-client/errors";
|
|
3
4
|
import type { SocialMentionDayPosts, SocialMentionPost, SocialMentionsPayload, SocialMentionsRange } from "../../../api-client/social-mentions";
|
|
@@ -56,7 +57,7 @@ export async function fetchSocialMentions(symbol: string, range: SocialMentionsR
|
|
|
56
57
|
client: Pick<typeof apiClient, "getCloudSocialMentions"> = apiClient): Promise<SocialMentionsPayload> {
|
|
57
58
|
try { return validateSocialMentions(await client.getCloudSocialMentions(symbol, range), symbol, range); }
|
|
58
59
|
catch (error) {
|
|
59
|
-
if (error instanceof ApiRequestError && error.status === 404) throw new Error("
|
|
60
|
+
if (error instanceof ApiRequestError && error.status === 404) throw new Error(unavailableText("social mentions"));
|
|
60
61
|
throw error;
|
|
61
62
|
}
|
|
62
63
|
}
|
|
@@ -3,7 +3,7 @@ import { getSharedMarketDataCoordinator, MarketDataCoordinator, resolveEntryValu
|
|
|
3
3
|
import type { InstrumentRef, OptionsRequest } from "../../../market-data/request-types";
|
|
4
4
|
import type { QueryEntry } from "../../../market-data/result-types";
|
|
5
5
|
import type { DataProvider } from "../../../types/data-provider";
|
|
6
|
-
import type { OptionsChain } from "../../../types/financials";
|
|
6
|
+
import type { OptionsChain, Quote } from "../../../types/financials";
|
|
7
7
|
import { daysToExpiryFrom } from "../shared/volatility";
|
|
8
8
|
import type { YieldPoint } from "../yield-curve/treasury-data";
|
|
9
9
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { abortable, abortError } from "../../../utils/async-deadline";
|
|
14
14
|
import { errorMessage } from "../../../utils/errors";
|
|
15
15
|
import { findListedExpiry, unlistedExpiryText } from "../../../utils/option-expiry";
|
|
16
|
+
import { optionModelSpot } from "../options/model-spot";
|
|
16
17
|
|
|
17
18
|
const DEFAULT_SURFACE_EXPIRY_LIMIT = 18;
|
|
18
19
|
const SURFACE_LOAD_CONCURRENCY = 4;
|
|
@@ -91,6 +92,14 @@ export interface SurfaceLoadRequest {
|
|
|
91
92
|
instrument: InstrumentRef;
|
|
92
93
|
spot: number;
|
|
93
94
|
spotAsOf?: string | number | null;
|
|
95
|
+
/**
|
|
96
|
+
* The underlying's quote, to read the spot from as OMON reads it
|
|
97
|
+
* (`optionModelSpot`): a chain from a closed session is priced at that
|
|
98
|
+
* session's official close, which the quote carries, so the same chain
|
|
99
|
+
* gives the same surface whatever trades before the next open. Without
|
|
100
|
+
* it, or without a chain to date, `spot` is used as given.
|
|
101
|
+
*/
|
|
102
|
+
quote?: Quote | null;
|
|
94
103
|
settings?: Partial<SurfaceSettings>;
|
|
95
104
|
limit?: number;
|
|
96
105
|
/** Listed expiries required by a selection, added to the representative sample. */
|
|
@@ -134,6 +143,9 @@ export async function loadVolatilitySurface(
|
|
|
134
143
|
let catalogueExpiration: number | null = null;
|
|
135
144
|
let treasuryError: string | null = null;
|
|
136
145
|
let finished = false;
|
|
146
|
+
let spot = request.spot;
|
|
147
|
+
let spotAsOf = request.spotAsOf ?? null;
|
|
148
|
+
let spotBasis: SurfaceSnapshot["spotBasis"] = null;
|
|
137
149
|
const checkAbort = () => { if (request.signal?.aborted) throw abortError(CANCELLED); };
|
|
138
150
|
const snapshot = (): SurfaceSnapshot => {
|
|
139
151
|
const failures: SurfaceFailure[] = [];
|
|
@@ -152,7 +164,7 @@ export async function loadVolatilitySurface(
|
|
|
152
164
|
}
|
|
153
165
|
const cached = models.get(expiration);
|
|
154
166
|
if (cached?.entry === entry && cached.curve === curve) return cached.value;
|
|
155
|
-
const value = buildSurfaceExpiry({ chain, expiration, spot
|
|
167
|
+
const value = buildSurfaceExpiry({ chain, expiration, spot, curve, settings, now,
|
|
156
168
|
source: entry.source, stale: !!entry.error || (entry.staleAt != null && entry.staleAt <= now), error });
|
|
157
169
|
models.set(expiration, { entry, curve, value });
|
|
158
170
|
return value;
|
|
@@ -166,7 +178,7 @@ export async function loadVolatilitySurface(
|
|
|
166
178
|
: failures.length > 0 ? (ready ? "partial" : "error") : "ready";
|
|
167
179
|
const dates = [...new Set(expiries.flatMap((expiry) => expiry.rateAsOf))];
|
|
168
180
|
return {
|
|
169
|
-
symbol, spot
|
|
181
|
+
symbol, spot, spotAsOf, ...spotBasis ? { spotBasis } : {}, phase, settings, catalogue: [...catalogue],
|
|
170
182
|
requested: selected.length, loaded, failed, expiries, failures,
|
|
171
183
|
warnings: [...new Set([...expiries.flatMap((expiry) => expiry.warnings), ...surfaceCalendarWarnings(expiries)])],
|
|
172
184
|
rateAsOf: dates.length === 1 ? dates[0]! : null, fetchedAt: now,
|
|
@@ -190,6 +202,14 @@ export async function loadVolatilitySurface(
|
|
|
190
202
|
{ forceRefresh: request.expiries ? false : request.forceRefresh }), request.signal, CANCELLED);
|
|
191
203
|
checkAbort();
|
|
192
204
|
const chain = resolveEntryValue(initial);
|
|
205
|
+
// Every expiry is valued at one spot, read against the catalogue chain's time before any expiry is fitted.
|
|
206
|
+
const modelSpot = request.quote ? optionModelSpot(request.quote, chain, now, request.instrument.exchange) : null;
|
|
207
|
+
if (modelSpot) {
|
|
208
|
+
spot = modelSpot.price;
|
|
209
|
+
spotAsOf = modelSpot.observedAt;
|
|
210
|
+
spotBasis = { basis: modelSpot.basis, session: modelSpot.session,
|
|
211
|
+
sessionDate: modelSpot.basis === "close" ? request.quote!.regularCloseSessionDate ?? null : null };
|
|
212
|
+
}
|
|
193
213
|
const initialContracts = chain ? [...chain.calls, ...chain.puts] : [];
|
|
194
214
|
const representedExpiry = initialContracts[0]?.expiration;
|
|
195
215
|
if (representedExpiry != null && initialContracts.every((contract) => contract.expiration === representedExpiry)
|
|
@@ -4,14 +4,15 @@ import {
|
|
|
4
4
|
expirationOptionSeconds, findListedExpiry, OPTION_EXPIRATION_FORMAT, OPTION_EXPIRATION_PLACEHOLDER, readOptionExpiration, unlistedExpiryText,
|
|
5
5
|
} from "../../../utils/option-expiry";
|
|
6
6
|
import { DATE_OPTION_EXAMPLES } from "../../../utils/calendar-date";
|
|
7
|
-
import { skewHeader } from "../iv-history/format";
|
|
7
|
+
import { IMPLIED_VOL_WORDS, skewHeader } from "../iv-history/format";
|
|
8
8
|
import { formatStrikeLabel } from "../options/table";
|
|
9
9
|
import { createSurfaceDependencies, loadVolatilitySurface } from "./client";
|
|
10
10
|
import { buildSurfaceGrid, type SurfaceGridOptions, type SurfaceSettings, type SurfaceSnapshot } from "./model";
|
|
11
|
-
import { formatCaptureTime, loadDatedSurface, storedSurfaceDate, type DatedSurfaceSnapshot } from "./stored";
|
|
11
|
+
import { atCapturePrecision, formatCaptureTime, loadDatedSurface, storedSurfaceDate, type DatedSurfaceSnapshot } from "./stored";
|
|
12
|
+
import { listingFateEmpty, loadListingFate } from "../../../tickers/listing-fate";
|
|
12
13
|
import {
|
|
13
14
|
expiryFeedFields, expiryLabel, formatIv, formatPrice, formatVolPoints, HIDDEN_SLOPE_TEXT, HIDDEN_YIELD_TEXT, selectSurfaceExpiry, surfaceForwardRow, surfaceSkewRow,
|
|
14
|
-
surfaceSmileRows, surfaceTermRows, TERM_SLOPE_MIN_DAYS, type SurfaceForwardRow, type SurfaceSkewRow,
|
|
15
|
+
surfaceSmileRows, surfaceSpotBasisLabel, surfaceTermRows, TERM_SLOPE_MIN_DAYS, type SurfaceForwardRow, type SurfaceSkewRow,
|
|
15
16
|
} from "./tables";
|
|
16
17
|
|
|
17
18
|
type SurfaceAxis = NonNullable<SurfaceGridOptions["axis"]>;
|
|
@@ -26,8 +27,8 @@ export function surfaceCoordinateLabel(axis: SurfaceAxis, coordinate: number, in
|
|
|
26
27
|
|
|
27
28
|
/** The Skew view's sign conventions and units, one line each. */
|
|
28
29
|
const SKEW_NOTES = [
|
|
29
|
-
"RR is 25D call IV minus 25D put IV, BF the average 25D wing IV minus
|
|
30
|
-
`Slope is the
|
|
30
|
+
"RR is 25D call IV minus 25D put IV, BF the average 25D wing IV minus the IV at the forward, 90/110 the IV at 90% of the forward minus the IV at 110%; all in vol points.",
|
|
31
|
+
`Slope is the change per year in the IV at the forward to the next listed expiry, in vol points; left out under ${TERM_SLOPE_MIN_DAYS} days to expiry, where it annualises a few hours of noise.`,
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -132,6 +133,7 @@ function forwardsSection(snapshot: SurfaceSnapshot): HeadlessBundleSection {
|
|
|
132
133
|
export const volSurfaceHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
133
134
|
shape: "bundle", argument: { kind: "ticker", description: "Underlying ticker" },
|
|
134
135
|
usListingsOnly: "Options data",
|
|
136
|
+
discovery: { suggestFor: IMPLIED_VOL_WORDS },
|
|
135
137
|
describe: (args) => `OVDV ${args.symbols[0] ?? ""}`,
|
|
136
138
|
options: [
|
|
137
139
|
{ key: "tab", type: "enum", values: SURFACE_TABS.map((value) => ({ value })),
|
|
@@ -154,6 +156,7 @@ export const volSurfaceHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
154
156
|
const date = typeof args.options.date === "string" && args.options.date ? storedSurfaceDate(args.options.date) : null;
|
|
155
157
|
let snapshot: DatedSurfaceSnapshot;
|
|
156
158
|
let quote: Quote | null = null;
|
|
159
|
+
let instrument: { symbol: string; exchange?: string } = { symbol };
|
|
157
160
|
if (date) {
|
|
158
161
|
// A stored close needs no current price: it carries the spot it was fitted at.
|
|
159
162
|
snapshot = await loadDatedSurface(symbol, date, { signal: ctx.signal, api: ctx.apiClient });
|
|
@@ -162,21 +165,35 @@ export const volSurfaceHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
162
165
|
snapshot = { ...snapshot, failures: [...snapshot.failures, { expiration: requested, message: unlistedExpiryText(requested, snapshot.catalogue) }] };
|
|
163
166
|
}
|
|
164
167
|
} else {
|
|
165
|
-
const
|
|
166
|
-
|
|
168
|
+
const resolved = await ctx.resolveInstrument?.(symbol) ?? { symbol };
|
|
169
|
+
instrument = resolved;
|
|
170
|
+
quote = await ctx.marketData.getQuote(symbol, resolved.exchange);
|
|
167
171
|
if (!(quote.price > 0) || !Number.isFinite(quote.price) || quote.stale) {
|
|
168
|
-
|
|
172
|
+
// A listing that stopped trading has no current price: what became of it is why (TWTR).
|
|
173
|
+
const fate = await loadListingFate(resolved, ctx.marketData, { quote });
|
|
174
|
+
return { sections: [], complete: false, unavailableSymbols: [symbol],
|
|
175
|
+
...listingFateEmpty(fate, ["A current underlying price is required"]) };
|
|
169
176
|
}
|
|
170
|
-
|
|
177
|
+
// At a stored close's precision, so the live report and a --date one export the same digits. A chain
|
|
178
|
+
// from a closed session is priced at that session's close, as OMON prices it.
|
|
179
|
+
snapshot = atCapturePrecision(await loadVolatilitySurface({ instrument, spot: quote.price, spotAsOf: quote.lastUpdated, quote,
|
|
171
180
|
settings: { ivSource: args.options.ivSource as SurfaceSettings["ivSource"], priceSide: args.options.priceSide as SurfaceSettings["priceSide"] },
|
|
172
181
|
limit: Number(args.options.limit), signal: ctx.signal,
|
|
173
182
|
requiredExpiries: requested == null ? [] : [requested],
|
|
174
|
-
}, createSurfaceDependencies(ctx.marketData, ctx.apiClient));
|
|
183
|
+
}, createSurfaceDependencies(ctx.marketData, ctx.apiClient)));
|
|
175
184
|
}
|
|
185
|
+
const available = snapshot.expiries.some((expiry) => expiry.fit);
|
|
186
|
+
const loadErrors = snapshot.failures.map((failure) => failure.expiration == null ? failure.message
|
|
187
|
+
: `${expiryLabel(failure.expiration)}: ${failure.message}`);
|
|
188
|
+
// A symbol with no option chain at all says what became of it when something did (FB is a fund now,
|
|
189
|
+
// Meta trades as META) in place of the chain's own error; a chain without clean quotes is the symbol's own.
|
|
190
|
+
const fate = !snapshot.stored && !available && snapshot.catalogue.length === 0
|
|
191
|
+
? listingFateEmpty(await loadListingFate(instrument, ctx.marketData, { quote }), loadErrors) : null;
|
|
192
|
+
// The spot a chain from a closed session was priced at, and its session.
|
|
193
|
+
const spotBasis = available ? surfaceSpotBasisLabel(snapshot) : null;
|
|
176
194
|
const stored = snapshot.stored ?? null;
|
|
177
195
|
const axis = args.options.axis as SurfaceAxis;
|
|
178
196
|
const tab = (SURFACE_TABS as readonly string[]).includes(String(args.options.tab)) ? args.options.tab as SurfaceTab : "surface";
|
|
179
|
-
const available = snapshot.expiries.some((expiry) => expiry.fit);
|
|
180
197
|
const expiryWarnings = snapshot.expiries.flatMap((expiry) => expiry.warnings.map((warning) =>
|
|
181
198
|
({ expiry: expiryLabel(expiry.expiration), warning })));
|
|
182
199
|
const perExpiry = new Set(expiryWarnings.map((row) => row.warning));
|
|
@@ -197,9 +214,11 @@ export const volSurfaceHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
197
214
|
],
|
|
198
215
|
complete: available && snapshot.failures.length === 0 && snapshot.expiries.every((expiry) => expiry.state === "ready"),
|
|
199
216
|
unavailableSymbols: available ? [] : [symbol],
|
|
200
|
-
errors: [...
|
|
201
|
-
|
|
202
|
-
...(stored || tab === "skew" ? { notes: [
|
|
217
|
+
errors: [...fate?.errors ?? loadErrors, ...smile?.errors ?? []],
|
|
218
|
+
...(fate?.empty ? { empty: fate.empty } : {}),
|
|
219
|
+
...(stored || spotBasis || tab === "skew" ? { notes: [
|
|
220
|
+
...stored ? storedNotes(snapshot, args.options) : spotBasis ? [`Spot ${formatPrice(snapshot.spot)}, ${spotBasis}.`] : [],
|
|
221
|
+
...tab === "skew" ? SKEW_NOTES : []] } : {}),
|
|
203
222
|
// A stored close is dated by its session, whichever view prints it.
|
|
204
223
|
...(stored ? { freshness: { status: "not-a-feed" as const, basis: "stored close capture", asOf: stored.sessionDate } }
|
|
205
224
|
: smile?.freshness ? { freshness: smile.freshness } : {}),
|
|
@@ -99,10 +99,23 @@ export interface SurfaceFailure {
|
|
|
99
99
|
reasonCode?: string;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* What a surface's spot is (`optionModelSpot`): the official close of the
|
|
104
|
+
* session the chain was quoted in (`close`, that session's New York date),
|
|
105
|
+
* or the latest price (`last`, with the extended session it printed in).
|
|
106
|
+
*/
|
|
107
|
+
interface SurfaceSpotBasis {
|
|
108
|
+
basis: "close" | "last";
|
|
109
|
+
session: "pre-market" | "after hours" | "overnight" | null;
|
|
110
|
+
sessionDate: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
export interface SurfaceSnapshot {
|
|
103
114
|
symbol: string;
|
|
104
115
|
spot: number;
|
|
105
116
|
spotAsOf?: string | number | null;
|
|
117
|
+
/** Absent when the caller gave a bare spot, as a stored close does. */
|
|
118
|
+
spotBasis?: SurfaceSpotBasis | null;
|
|
106
119
|
phase: "loading" | "partial" | "ready" | "error";
|
|
107
120
|
settings: SurfaceSettings;
|
|
108
121
|
catalogue: number[];
|
|
@@ -401,11 +414,16 @@ export function buildSurfaceExpiry(input: BuildSurfaceExpiryInput): SurfaceExpir
|
|
|
401
414
|
}
|
|
402
415
|
|
|
403
416
|
/**
|
|
404
|
-
* A fitted expiry's skew in decimal IV
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
417
|
+
* A fitted expiry's skew in decimal IV, measured from its forward: `atm` is
|
|
418
|
+
* the IV at the forward (ATMF), RR the 25-delta call minus put, BF their
|
|
419
|
+
* average minus ATMF, 90/110 the IV at 90% of the forward minus 110%. The
|
|
420
|
+
* spot only enters through the 25-delta strikes' spot delta, whose carry is
|
|
421
|
+
* the parity forward's, so a pre-market print against the close's chain does
|
|
422
|
+
* not move a fly, and a long-dated fly is not dragged below zero by the skew
|
|
423
|
+
* between spot and a forward several percent above it. The 25-delta strikes
|
|
424
|
+
* are found on the fit inside the quoted strikes, as the Table's delta
|
|
425
|
+
* columns find them, so a fit that cannot price a far wing it was never
|
|
426
|
+
* quoted at does not blank a quoted expiry's skew.
|
|
409
427
|
*/
|
|
410
428
|
export function surfaceExpirySkew(expiry: SurfaceExpiry, spot: number): SkewMetrics {
|
|
411
429
|
const smile = (strike: number) => evaluateSurfaceSmile(expiry, strike);
|
|
@@ -413,8 +431,9 @@ export function surfaceExpirySkew(expiry: SurfaceExpiry, spot: number): SkewMetr
|
|
|
413
431
|
const strike = deltaStrike(expiry, spot, delta);
|
|
414
432
|
return strike == null ? null : smile(strike);
|
|
415
433
|
};
|
|
416
|
-
const
|
|
417
|
-
const
|
|
434
|
+
const forward = expiry.forward;
|
|
435
|
+
const put25 = at(-0.25), call25 = at(0.25), atm = positive(forward) ? smile(forward) : null;
|
|
436
|
+
const low = positive(forward) ? smile(forward * 0.9) : null, high = positive(forward) ? smile(forward * 1.1) : null;
|
|
418
437
|
const both = put25 != null && call25 != null;
|
|
419
438
|
return { atm, put25, call25, putCallSkew: both ? put25 - call25 : null, riskReversal: both ? call25 - put25 : null,
|
|
420
439
|
butterfly: both && atm != null ? (put25 + call25) / 2 - atm : null,
|
|
@@ -433,12 +452,13 @@ export function surfaceCalendarWarnings(expiries: readonly SurfaceExpiry[]): str
|
|
|
433
452
|
return count > 0 ? [`${count} calendar arbitrage ${count === 1 ? "warning" : "warnings"}`] : [];
|
|
434
453
|
}
|
|
435
454
|
|
|
455
|
+
/** The ATM-forward IV change per year to the next expiry that has one: spot plays no part in it. */
|
|
436
456
|
export function withSurfaceTermSlopes(expiries: readonly SurfaceExpiry[]): SurfaceExpiry[] {
|
|
437
457
|
const sorted = [...expiries].sort((a, b) => a.years - b.years);
|
|
438
458
|
return sorted.map((expiry, index) => {
|
|
439
|
-
const next = sorted.slice(index + 1).find((item) => item.
|
|
440
|
-
return { ...expiry, termSlope: expiry.
|
|
441
|
-
? volatilityTermSlope({ years: expiry.years, volatility: expiry.
|
|
459
|
+
const next = sorted.slice(index + 1).find((item) => item.skew.atm != null);
|
|
460
|
+
return { ...expiry, termSlope: expiry.skew.atm != null && next?.skew.atm != null
|
|
461
|
+
? volatilityTermSlope({ years: expiry.years, volatility: expiry.skew.atm }, { years: next.years, volatility: next.skew.atm }) : null };
|
|
442
462
|
});
|
|
443
463
|
}
|
|
444
464
|
|
|
@@ -39,7 +39,7 @@ import { VolatilitySurface } from "./surface";
|
|
|
39
39
|
import { SmileChart, TermChart } from "./charts";
|
|
40
40
|
import { surfaceCoordinateLabel } from "./headless";
|
|
41
41
|
import { expiryLabel, formatIv, formatPrice, formatVolPoints, HIDDEN_SLOPE_TEXT, HIDDEN_YIELD_TEXT, selectSurfaceExpiry, surfaceForwardRow,
|
|
42
|
-
surfaceSkewRow } from "./tables";
|
|
42
|
+
surfaceSkewRow, surfaceSpotBasisLabel } from "./tables";
|
|
43
43
|
import { findListedExpiry, unlistedExpiryText } from "../../../utils/option-expiry";
|
|
44
44
|
|
|
45
45
|
const TABS = [
|
|
@@ -95,17 +95,19 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
|
|
|
95
95
|
const spotAvailable = !!quote && quote.price > 0 && Number.isFinite(quote.price) && !quote.stale;
|
|
96
96
|
const spotAsOfRef = useRef(quote?.lastUpdated ?? null);
|
|
97
97
|
spotAsOfRef.current = quote?.lastUpdated ?? null;
|
|
98
|
-
const
|
|
99
|
-
|
|
98
|
+
const quoteRef = useRef(quote ?? null);
|
|
99
|
+
quoteRef.current = quote ?? null;
|
|
100
100
|
const requestKey = JSON.stringify([target?.cacheKey, symbol, axis, tenors, ivSource, priceSide, spread, age, limit, spotAvailable, requestedExpiration]);
|
|
101
101
|
// Every load reads the spot at the moment it starts; a reload in session
|
|
102
102
|
// therefore uses the current stream price, not the one from the first load.
|
|
103
|
+
// A chain from a closed session is priced at that session's close, which
|
|
104
|
+
// the quote carries, so a pre-market print does not move it.
|
|
103
105
|
const surfaceRequest = useCallback((force: boolean, signal: AbortSignal): SurfaceLoadRequest => {
|
|
104
106
|
const instrument = target?.instrument;
|
|
105
107
|
return {
|
|
106
108
|
instrument: { symbol: target?.effectiveTicker ?? symbol!, exchange: target?.effectiveExchange ?? "",
|
|
107
109
|
brokerId: instrument?.brokerId, brokerInstanceId: instrument?.brokerInstanceId, instrument },
|
|
108
|
-
spot:
|
|
110
|
+
spot: quoteRef.current?.price ?? 0, spotAsOf: spotAsOfRef.current, quote: quoteRef.current, limit, forceRefresh: force, signal,
|
|
109
111
|
requiredExpiries: expirationRef.current == null ? [] : [expirationRef.current],
|
|
110
112
|
settings: { ...DEFAULT_SURFACE_SETTINGS, ivSource, priceSide, maxRelativeSpread: Number(spread), maxStaleSessions: Number(age) },
|
|
111
113
|
};
|
|
@@ -153,6 +155,14 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
|
|
|
153
155
|
const underlying = (target?.effectiveTicker ?? symbol ?? "").toUpperCase();
|
|
154
156
|
const datesLoader = useCallback(async () => (await loadSurfaceDates(underlying)).dates, [underlying]);
|
|
155
157
|
const dates = useAsyncResource(underlying ? datesLoader : null);
|
|
158
|
+
// A symbol with no current price, or no option chain at all, says what became of it when something
|
|
159
|
+
// did, as `quote` says it: TWTR stopped trading, FB is a fund now and Meta trades as META.
|
|
160
|
+
const noChain = !historyDate && !!quote && (!spotAvailable
|
|
161
|
+
|| (!!resource.data && !resource.loading && resource.data.catalogue.length === 0));
|
|
162
|
+
const fateExchange = target?.effectiveExchange ?? "";
|
|
163
|
+
const fateLoader = useCallback(async () => (await getSharedMarketDataCoordinator()
|
|
164
|
+
?.loadListingFate({ symbol: underlying, exchange: fateExchange }))?.note ?? null, [underlying, fateExchange]);
|
|
165
|
+
const fateNote = useAsyncResource(underlying && noChain ? fateLoader : null).data ?? null;
|
|
156
166
|
const ivRank = useIvRank(underlying || null);
|
|
157
167
|
useEffect(() => () => controller.current?.abort(), [request]);
|
|
158
168
|
const incremental = partial?.key === requestKey ? partial.snapshot : null;
|
|
@@ -229,6 +239,11 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
|
|
|
229
239
|
const nearestRow = (years: number | undefined) => years == null || !denseGrid?.tenors.length ? -1
|
|
230
240
|
: denseGrid.tenors.reduce((best, value, index) => Math.abs(value - years) < Math.abs(denseGrid.tenors[best]! - years) ? index : best, 0);
|
|
231
241
|
const selectedExpiry = snapshot ? selectSurfaceExpiry(snapshot, expiration) : null;
|
|
242
|
+
// Another date loads under the query bar, which keeps the last surface's expiries until it answers.
|
|
243
|
+
const lastShown = useRef<{ underlying: string; snapshot: DatedSurfaceSnapshot } | null>(null);
|
|
244
|
+
if (snapshot) lastShown.current = { underlying, snapshot };
|
|
245
|
+
const barSnapshot = snapshot ?? (lastShown.current?.underlying === underlying ? lastShown.current.snapshot : null);
|
|
246
|
+
const barExpiry = barSnapshot ? selectSurfaceExpiry(barSnapshot, expiration) : null;
|
|
232
247
|
const openChain = useCallback(() => {
|
|
233
248
|
if (!snapshot) return;
|
|
234
249
|
const selected = pinned ?? selectedExpiry?.expiration;
|
|
@@ -334,7 +349,7 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
|
|
|
334
349
|
for (const entry of snapshot?.expiries ?? []) {
|
|
335
350
|
for (const warning of entry.warnings) expiryWarnings.set(warning, [...expiryWarnings.get(warning) ?? [], entry.expiration]);
|
|
336
351
|
}
|
|
337
|
-
const notices = [...(snapshot?.warnings ?? []).filter((warning) => !expiryWarnings.has(warning)), ...failures,
|
|
352
|
+
const notices = fateNote ? [] : [...(snapshot?.warnings ?? []).filter((warning) => !expiryWarnings.has(warning)), ...failures,
|
|
338
353
|
...(activeTab === "surface" && sheet?.omitted.length ? [`3D sheet omits ${sheet.omitted.map((entry) => expiryLabel(entry.expiration)).join(", ")}: smile fit fell back to interpolation (shown in Table)`] : []),
|
|
339
354
|
...[...expiryWarnings].map(([warning, expirations]) => `${expirations.length > 1 && expirations.length === snapshot?.expiries.length
|
|
340
355
|
? "All expiries" : expirations.map(expiryLabel).join(", ")}: ${warning}`),
|
|
@@ -345,6 +360,7 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
|
|
|
345
360
|
&& !snapshot.failures.some((failure) => failure.expiration === pinned)
|
|
346
361
|
? [`${expiryLabel(pinned)}: ${unlistedExpiryText(pinned, snapshot.catalogue)}`] : []),
|
|
347
362
|
...(active.error ? [active.error] : [])];
|
|
363
|
+
const spotBasis = snapshot && !snapshot.stored ? surfaceSpotBasisLabel(snapshot) : null;
|
|
348
364
|
// What the quotes are and when they were observed; a reload moves the time.
|
|
349
365
|
const freshness = !snapshot ? null : snapshot.stored ? `stored close ${snapshot.stored.sessionDate}`
|
|
350
366
|
: surfaceFreshnessLabel(snapshot, CLOUD_QUOTE_DELAY_MINUTES);
|
|
@@ -447,24 +463,28 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
|
|
|
447
463
|
useEffect(() => setTableOnScreen(tableShown), [tableShown]);
|
|
448
464
|
return <Box flexDirection="column" width={width} overflow="hidden" {...(nativePaneChrome ? FILL_REST : { height })}>
|
|
449
465
|
{tabStrip}
|
|
450
|
-
{!symbol ? <EmptyState title="Choose an underlying ticker." /> :
|
|
451
|
-
|
|
452
|
-
<QueryBar width={width}
|
|
466
|
+
{!symbol ? <EmptyState title="Choose an underlying ticker." /> : <>
|
|
467
|
+
{barSnapshot ? <QueryBar width={width}
|
|
453
468
|
filters={[
|
|
454
|
-
{ id: "expiry", label: "Expiry", value: String(pinned ??
|
|
455
|
-
options:
|
|
469
|
+
{ id: "expiry", label: "Expiry", value: String(pinned ?? barExpiry?.expiration ?? ""),
|
|
470
|
+
options: barSnapshot.catalogue.map((value) => ({ value: String(value), label: expiryLabel(value) })),
|
|
456
471
|
onChange: (value: string) => setExpiration(Number(value)) },
|
|
457
472
|
...(storedDates.length ? [{ id: "date", label: "Date", value: shownDate ?? "", defaultValue: "",
|
|
458
473
|
options: [{ value: "", label: "Live" }, ...storedDates.map((date) => ({ value: date, label: date }))],
|
|
459
474
|
onChange: (value: string) => setHistoryDate(value || null) }] : []),
|
|
460
475
|
]}
|
|
461
|
-
meta={snapshot
|
|
476
|
+
meta={!snapshot ? undefined : snapshot.stored
|
|
462
477
|
? `Stored close · spot ${formatPrice(snapshot.spot)} · captured ${formatCaptureTime(snapshot.stored.capturedAt)} New York`
|
|
463
|
-
: [`Spot ${formatPrice(snapshot
|
|
478
|
+
: [`Spot ${formatPrice(snapshot.spot)}${quote?.currency ? ` ${quote.currency}` : ""}`,
|
|
479
|
+
// The close a closed session's chain is priced at dates the chain too.
|
|
480
|
+
spotBasis, snapshot.spotBasis?.basis === "close" ? null : selectedExpiry?.asOf?.slice(0, 10)].filter(Boolean).join(" · ")} /> : null}
|
|
481
|
+
<PaneStatusBody loading={active.loading && !snapshot && !fateNote}
|
|
482
|
+
error={!snapshot && !fateNote ? active.error : null} empty={!!fateNote || !snapshot && !active.loading}
|
|
483
|
+
emptyTitle={fateNote ?? undefined} subject="volatility surface">
|
|
464
484
|
<StatGrid items={ivStats} width={width} />
|
|
465
485
|
{/* The terminal sizes the view in the rows under the bar and figures; the desktop fills what is left. */}
|
|
466
486
|
{nativePaneChrome ? <Box flexDirection="column" overflow="hidden" {...FILL_REST}>{content}</Box> : content}
|
|
467
|
-
</PaneStatusBody
|
|
487
|
+
</PaneStatusBody></>}
|
|
468
488
|
</Box>;
|
|
469
489
|
}
|
|
470
490
|
|
|
@@ -3,7 +3,10 @@ import { logForwardMoneyness, type ExpectedMove, type SkewMetrics, type SmileFit
|
|
|
3
3
|
import { ivSymbol, loadStoredSurface, loadSurfaceDates, type ImpliedVolatilityApi, type StoredSurfacePayload } from "../iv-history/client";
|
|
4
4
|
import { ApiRequestError } from "../../../api-client/errors";
|
|
5
5
|
import { CLOUD_QUOTE_DELAY_MINUTES } from "../../../api-client/plan-rules";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
evaluateSurfaceSmile, normalizeSurfaceSettings, surfaceExpirySkew, withSurfaceTermSlopes,
|
|
8
|
+
type SurfaceExpiry, type SurfaceFilterCounts, type SurfaceSnapshot,
|
|
9
|
+
} from "./model";
|
|
7
10
|
import { isFiniteNumber } from "../../../utils/guards";
|
|
8
11
|
import { dateOptionError, readCalendarDate } from "../../../utils/calendar-date";
|
|
9
12
|
|
|
@@ -63,6 +66,10 @@ function storedExpiry(entry: StoredExpiry, spot: number, capturedAt: number, sou
|
|
|
63
66
|
}
|
|
64
67
|
expiry.points.sort((a, b) => a.strike - b.strike);
|
|
65
68
|
for (const point of expiry.points) point.fitResidual = point.volatility - (evaluateSurfaceSmile(expiry, point.strike) ?? point.volatility);
|
|
69
|
+
// The skew is read off the stored fit as the live surface reads it, not taken as stored: a capture
|
|
70
|
+
// that searched the far wings for its 25-delta strikes left a quoted monthly blank, and an older one
|
|
71
|
+
// measured its fly from the spot.
|
|
72
|
+
if (expiry.fit) expiry.skew = surfaceExpirySkew(expiry, spot);
|
|
66
73
|
return expiry;
|
|
67
74
|
}
|
|
68
75
|
|
|
@@ -120,13 +127,37 @@ export async function loadDatedSurface(symbol: string, date: string, options: {
|
|
|
120
127
|
throw new Error(storedSurfaceMiss(bare, date, dates.value.dates));
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
/** A figure to the decimals a stored close keeps it at. */
|
|
131
|
+
const toDecimals = (value: number | null, digits: number) => value == null || !Number.isFinite(value) ? null : Number(value.toFixed(digits));
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A live surface at the precision a stored close keeps its figures (the
|
|
135
|
+
* platform's compact capture: years to 8 decimals, forwards and mids to 4,
|
|
136
|
+
* rates, yields, IVs and the term slope to 6), so a report of the live chain
|
|
137
|
+
* and one of a stored close (`--date`) export the same digits. The fit and
|
|
138
|
+
* the skew are kept whole, as a capture keeps them; residuals are measured
|
|
139
|
+
* again from the rounded IVs, as a stored close measures them.
|
|
140
|
+
*/
|
|
141
|
+
export function atCapturePrecision(snapshot: SurfaceSnapshot): SurfaceSnapshot {
|
|
142
|
+
return { ...snapshot, expiries: snapshot.expiries.map((expiry) => {
|
|
143
|
+
const rounded: SurfaceExpiry = { ...expiry, years: toDecimals(expiry.years, 8) ?? expiry.years, rate: toDecimals(expiry.rate, 6),
|
|
144
|
+
forward: toDecimals(expiry.forward, 4), dividendYield: toDecimals(expiry.dividendYield, 6), atmIV: toDecimals(expiry.atmIV, 6),
|
|
145
|
+
termSlope: toDecimals(expiry.termSlope, 6),
|
|
146
|
+
points: expiry.points.map((point) => ({ ...point, volatility: toDecimals(point.volatility, 6) ?? point.volatility, mid: toDecimals(point.mid, 4) ?? point.mid })) };
|
|
147
|
+
for (const point of rounded.points) {
|
|
148
|
+
if (point.fitResidual != null) point.fitResidual = point.volatility - (evaluateSurfaceSmile(rounded, point.strike) ?? point.volatility);
|
|
149
|
+
}
|
|
150
|
+
return rounded;
|
|
151
|
+
}) };
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
/** A stored close surface in the shape the live OVDV views render: mid quotes, recomputed IV. */
|
|
124
155
|
export function storedSurfaceSnapshot(payload: StoredSurfacePayload): DatedSurfaceSnapshot {
|
|
125
156
|
const surface = payload.surface as unknown as StoredSurface;
|
|
126
157
|
if (!surface || surface.version !== 1 || !Array.isArray(surface.expiries)) throw new Error("Unsupported stored surface format");
|
|
127
158
|
const capturedAt = Date.parse(payload.capturedAt);
|
|
128
|
-
const expiries = surface.expiries.map((entry) => storedExpiry(entry, payload.spot, capturedAt, surface.source))
|
|
129
|
-
.filter((entry) => entry.points.length > 0)
|
|
159
|
+
const expiries = withSurfaceTermSlopes(surface.expiries.map((entry) => storedExpiry(entry, payload.spot, capturedAt, surface.source))
|
|
160
|
+
.filter((entry) => entry.points.length > 0));
|
|
130
161
|
const rateDates = expiries.flatMap((entry) => entry.rateAsOf).sort();
|
|
131
162
|
return {
|
|
132
163
|
symbol: payload.symbol, spot: payload.spot, spotAsOf: surface.spotAsOf ?? payload.capturedAt, phase: "ready",
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { findListedExpiry } from "../../../utils/option-expiry";
|
|
2
2
|
import { evaluateSurfaceSmile, type SurfaceExpiry, type SurfaceSnapshot } from "./model";
|
|
3
|
+
import { modelSpotStamp } from "../options/model-spot";
|
|
3
4
|
|
|
4
5
|
export const formatIv = (value: number | null | undefined) => value == null || !Number.isFinite(value) ? "--" : `${(value * 100).toFixed(2)}%`;
|
|
5
6
|
export const formatPrice = (value: number | null | undefined) => value == null || !Number.isFinite(value) ? "--" : value.toFixed(2);
|
|
6
7
|
export const expiryLabel = (expiration: number) => new Date(expiration * 1000).toISOString().slice(0, 10);
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The shared stamp for the spot a surface was fitted at (`modelSpotStamp`):
|
|
11
|
+
* `close Mon 19 Oct` for the official close of the session a closed chain was
|
|
12
|
+
* quoted in, `pre-market Tue 20 Oct 12:21 UTC` for an extended-session print.
|
|
13
|
+
* Null for the regular session's price or a bare spot.
|
|
14
|
+
*/
|
|
15
|
+
export function surfaceSpotBasisLabel(snapshot: Pick<SurfaceSnapshot, "spotBasis" | "spotAsOf">): string | null {
|
|
16
|
+
const basis = snapshot.spotBasis;
|
|
17
|
+
if (!basis) return null;
|
|
18
|
+
const observedAt = typeof snapshot.spotAsOf === "number" ? snapshot.spotAsOf : Date.parse(String(snapshot.spotAsOf));
|
|
19
|
+
return modelSpotStamp({ basis: basis.basis, session: basis.session, observedAt });
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
/** A one-day front expiry is the noisiest smile on the board; the default selection is the first at least four weeks out. */
|
|
9
23
|
const DEFAULT_EXPIRY_MIN_DAYS = 28;
|
|
10
24
|
/** Annualising a few days of parity carry produces meaningless yields. */
|
|
@@ -670,7 +670,7 @@ export function WebPageStackView({
|
|
|
670
670
|
return (
|
|
671
671
|
<Box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} minHeight={0} overflow="hidden">
|
|
672
672
|
{!headerAttached && (
|
|
673
|
-
<div className="gloom-stack-bar" data-gloom-role="page-stack-header">
|
|
673
|
+
<div className="gloom-stack-bar" data-gloom-role="page-stack-header" data-gloom-top-surface="">
|
|
674
674
|
<button
|
|
675
675
|
type="button"
|
|
676
676
|
className="gloom-stack-back"
|
|
@@ -290,6 +290,8 @@ export function WebQueryBar({ search, items, view, onClearAll, meta, leading, op
|
|
|
290
290
|
ref={rootRef}
|
|
291
291
|
className="gloom-qb"
|
|
292
292
|
data-gloom-role="query-bar"
|
|
293
|
+
// The active title-bar tab above takes this band's colour (top-surface.ts).
|
|
294
|
+
data-gloom-top-surface=""
|
|
293
295
|
// A search with nothing beside it may take the room the filters would.
|
|
294
296
|
data-search-only={search && items.length === 0 && !view ? "true" : undefined}
|
|
295
297
|
>
|