gloomberb 0.16.0 → 0.16.1
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/api-client/data.ts +18 -3
- package/src/api-client/index.ts +3 -0
- package/src/api-client/members.ts +26 -0
- package/src/api-client/perps.ts +4 -0
- package/src/api-client/types.ts +7 -1
- package/src/app/global-shortcuts.ts +14 -6
- package/src/app/keybindings/actions.ts +1 -1
- package/src/app/runtime/update.ts +38 -2
- package/src/app.tsx +10 -6
- package/src/cli/pane-functions/function-help.ts +9 -2
- package/src/components/command-bar/commands/collection.ts +5 -1
- package/src/components/command-bar/routes/root/command-items.ts +9 -2
- package/src/components/command-bar/routes/root/plugin-install.ts +2 -2
- package/src/components/command-bar/routes/root/provider-search.ts +19 -5
- package/src/components/command-bar/routes/root/runtime.ts +14 -5
- package/src/components/command-bar/routes/ticker-search/results.ts +31 -5
- package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
- package/src/components/command-bar/routing/actions.ts +2 -0
- package/src/components/command-bar/selection-runtime.ts +38 -0
- package/src/components/command-bar/surface/index.tsx +7 -2
- package/src/components/index.ts +2 -2
- package/src/components/layout/floating-pane.tsx +6 -0
- package/src/components/layout/header.tsx +24 -7
- package/src/components/layout/missing-pane.tsx +2 -2
- package/src/components/layout/pane/header.tsx +63 -30
- package/src/components/layout/pane/index.tsx +6 -0
- package/src/components/layout/pane/title.ts +31 -7
- package/src/components/layout/shell/drag/index.ts +6 -5
- package/src/components/layout/shell/drag/runtime.ts +3 -0
- package/src/components/layout/shell/index.tsx +6 -0
- package/src/components/layout/shell/pane/layers.tsx +15 -7
- package/src/components/layout/shell/terminal-pointer-runtime.ts +24 -3
- package/src/components/layout/shell/ticker-link-menu.ts +23 -9
- package/src/components/metric-treemap/heat-surface.tsx +718 -0
- package/src/components/metric-treemap/heat-tiles.ts +210 -0
- package/src/components/metric-treemap/index.tsx +2 -0
- package/src/components/metric-treemap/layout.ts +6 -0
- package/src/components/metric-treemap/scene.ts +432 -0
- package/src/components/pane-settings-dialog/field-dialogs.tsx +1 -0
- package/src/components/toggle-list.tsx +38 -12
- package/src/components/ui/country-flag-art.tsx +449 -0
- package/src/components/ui/country-flag.tsx +41 -0
- package/src/components/ui/data-table/types.ts +13 -0
- package/src/components/ui/icon.tsx +2 -0
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/multi-select/dialog.tsx +108 -22
- package/src/components/ui/range-track.tsx +46 -0
- package/src/components/ui/tabs.tsx +4 -0
- package/src/core/state/app/types.ts +10 -1
- package/src/i18n/es.ts +5 -3
- package/src/i18n/ja.ts +5 -3
- package/src/i18n/ko.ts +5 -3
- package/src/i18n/zh-cn.ts +5 -3
- package/src/i18n/zh-tw.ts +5 -3
- package/src/layout/ticker-navigation.ts +28 -2
- package/src/market-data/quotes/contributions.ts +6 -0
- package/src/market-data/quotes/freshness.ts +36 -0
- package/src/market-data/quotes/resolution.ts +22 -8
- package/src/market-data/quotes/time.ts +16 -0
- package/src/plugins/builtin/analytics/risk-client.ts +185 -59
- package/src/plugins/builtin/analytics/risk-headless.ts +55 -49
- package/src/plugins/builtin/analytics/risk-math.ts +33 -13
- package/src/plugins/builtin/analytics/risk-model.ts +250 -60
- package/src/plugins/builtin/analytics/risk-pane.tsx +61 -30
- package/src/plugins/builtin/analytics/risk-test-data.ts +182 -0
- package/src/plugins/builtin/buildout/cells.tsx +15 -12
- package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
- package/src/plugins/builtin/buildout/detail/index.tsx +0 -1
- package/src/plugins/builtin/buildout/detail/intel.tsx +6 -6
- package/src/plugins/builtin/buildout/detail/site.tsx +4 -7
- package/src/plugins/builtin/buildout/detail/ui.tsx +0 -19
- package/src/plugins/builtin/catalysts/index.tsx +2 -2
- package/src/plugins/builtin/catalysts/pane.tsx +3 -1
- package/src/plugins/builtin/cds/index.tsx +1 -0
- package/src/plugins/builtin/debt-maturities/wall-bar.tsx +14 -41
- package/src/plugins/builtin/dividend-yield/client.ts +4 -2
- package/src/plugins/builtin/earnings/client.ts +2 -2
- package/src/plugins/builtin/earnings-calls/index.tsx +1 -0
- package/src/plugins/builtin/fx-matrix/direction.ts +81 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +71 -33
- package/src/plugins/builtin/fx-matrix/live-legs.ts +17 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +68 -14
- package/src/plugins/builtin/fx-matrix/settings.ts +62 -0
- package/src/plugins/builtin/jobs/index.tsx +1 -0
- package/src/plugins/builtin/jobs/share-bars.tsx +5 -28
- package/src/plugins/builtin/market-heatmap/data.ts +28 -2
- package/src/plugins/builtin/market-heatmap/index.tsx +250 -79
- package/src/plugins/builtin/market-heatmap/live.ts +88 -0
- package/src/plugins/builtin/market-heatmap/portfolio.ts +27 -7
- package/src/plugins/builtin/market-movers/client.ts +3 -2
- package/src/plugins/builtin/market-movers/headless.ts +8 -1
- package/src/plugins/builtin/market-movers/screener.ts +18 -5
- package/src/plugins/builtin/members/client.ts +36 -0
- package/src/plugins/builtin/members/headless.ts +33 -0
- package/src/plugins/builtin/members/index.tsx +20 -0
- package/src/plugins/builtin/members/model.ts +60 -0
- package/src/plugins/builtin/members/pane.tsx +168 -0
- package/src/plugins/builtin/mna/index.tsx +2 -1
- package/src/plugins/builtin/mna/pane.tsx +5 -2
- package/src/plugins/builtin/options/index.tsx +2 -1
- package/src/plugins/builtin/pe-band/client.ts +29 -2
- package/src/plugins/builtin/pe-band/headless.ts +2 -2
- package/src/plugins/builtin/pe-band/model.ts +11 -7
- package/src/plugins/builtin/pe-band/pane.tsx +2 -1
- package/src/plugins/builtin/pe-band/report-dates.ts +78 -0
- package/src/plugins/builtin/perps/client.ts +17 -3
- package/src/plugins/builtin/perps/equity-identity.ts +16 -0
- package/src/plugins/builtin/perps/equity-row.tsx +6 -4
- package/src/plugins/builtin/plugin-marketplace/activation.ts +19 -0
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -2
- package/src/plugins/builtin/plugin-marketplace/store.ts +2 -0
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +43 -2
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +48 -5
- package/src/plugins/builtin/scanner/hilo-bars.tsx +12 -70
- package/src/plugins/builtin/scanner/hilo-model.ts +0 -18
- package/src/plugins/builtin/sectors/move-bar.tsx +10 -32
- package/src/plugins/builtin/social-mentions/index.tsx +1 -1
- package/src/plugins/builtin/thirteenf/index.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/overview/components.tsx +5 -60
- package/src/plugins/builtin/ticker-detail/overview/pane.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/quote-footer.ts +7 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +20 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +2 -2
- package/src/plugins/builtin/vol-surface/index.tsx +1 -1
- package/src/plugins/catalog-browser.ts +2 -0
- package/src/plugins/catalog-manifest.ts +1 -0
- package/src/plugins/catalog-ui.ts +3 -1
- package/src/plugins/catalog.ts +1 -1
- package/src/plugins/host-link.ts +33 -1
- package/src/plugins/installer.ts +48 -12
- package/src/plugins/loader.ts +18 -3
- package/src/plugins/manager-node.ts +15 -5
- package/src/plugins/registry/index.ts +1 -1
- package/src/plugins/registry/pane-settings.ts +25 -7
- package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
- package/src/renderers/dom/data-table/row.tsx +50 -16
- package/src/renderers/dom/desktop/icons.tsx +1 -0
- package/src/renderers/dom/host/box.tsx +69 -26
- package/src/renderers/dom/host/tabs.tsx +37 -1
- package/src/renderers/dom/styles.css +40 -5
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +11 -6
- package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/update.ts +181 -87
- package/src/renderers/electrobun/bun/index.ts +73 -6
- package/src/renderers/electrobun/bun/window/remembered-frame.ts +122 -0
- package/src/renderers/electrobun/shared/protocol.ts +3 -0
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +2 -1
- package/src/renderers/electrobun/view/main.tsx +7 -1
- package/src/renderers/electrobun/view/renderer-window-error.ts +18 -0
- package/src/renderers/electrobun/view/update-host.ts +45 -21
- package/src/sources/gloomberb-cloud/normalizers.ts +9 -2
- package/src/sources/provider-router/financials.ts +3 -1
- package/src/telemetry/benign-window-error.ts +7 -0
- package/src/telemetry/crash-reports-dom.ts +2 -0
- package/src/test-support/daily-nav.ts +25 -0
- package/src/theme/heat-colors.ts +174 -1
- package/src/tickers/instrument-kind.ts +34 -7
- package/src/tickers/search/asset-classes.ts +24 -9
- package/src/tickers/search/index.ts +44 -11
- package/src/tickers/search/ranking.ts +23 -3
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +18 -5
- package/src/ui/context-menu.tsx +1 -0
- package/src/ui/host.tsx +2 -0
- package/src/updater/index.ts +29 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Quote } from "../../types/financials";
|
|
2
2
|
import { isUsListingExchange } from "../../utils/exchanges";
|
|
3
|
+
import { zonedDateKey } from "../../utils/zoned-date-time";
|
|
4
|
+
import { getPublishedUsEquitySession } from "../published-us-sessions";
|
|
3
5
|
import { quoteFutureToleranceMs } from "./clock";
|
|
4
6
|
import {
|
|
5
7
|
activeUsExtendedHoursSession,
|
|
@@ -7,6 +9,9 @@ import {
|
|
|
7
9
|
isUsPriorSessionPremarketQuote,
|
|
8
10
|
} from "../market/freshness";
|
|
9
11
|
|
|
12
|
+
// Give the next regular session's NAV time to publish after its actual close.
|
|
13
|
+
const NAV_PUBLICATION_ALLOWANCE_MS = 12 * 60 * 60_000;
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Receipt time cannot establish when the source observed a quoted price. A
|
|
12
17
|
* stamp slightly ahead of local time is clock skew, not a bad observation.
|
|
@@ -18,6 +23,36 @@ export function hasValidQuoteObservationTime(quote: Pick<Quote, "lastUpdated">,
|
|
|
18
23
|
&& Number.isFinite(new Date(timestamp).getTime()) && Number.isFinite(new Date(now).getTime());
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** The source date of a validated daily fund observation, including an old NAV. */
|
|
27
|
+
export function dailyNavDate(quote: Quote, now = Date.now()): string | null {
|
|
28
|
+
if (quote.priceObservation !== "nav" || quote.instrumentType !== "MUTUALFUND"
|
|
29
|
+
|| (quote.priceBasis !== undefined && quote.priceBasis !== "per-unit")
|
|
30
|
+
|| !Number.isFinite(quote.price) || quote.price <= 0
|
|
31
|
+
|| !hasValidQuoteObservationTime(quote, now) || quote.lastUpdated > now) return null;
|
|
32
|
+
const exchange = quote.listingExchangeName || quote.exchangeName;
|
|
33
|
+
const date = quote.changeSessionDate;
|
|
34
|
+
if (typeof exchange !== "string" || typeof date !== "string"
|
|
35
|
+
|| getPublishedUsEquitySession(exchange, date)?.kind !== "session"
|
|
36
|
+
|| zonedDateKey(quote.lastUpdated, "America/New_York") !== date) return null;
|
|
37
|
+
return date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isDailyNavStale(quote: Quote, now: number): boolean {
|
|
41
|
+
const date = dailyNavDate(quote, now);
|
|
42
|
+
if (!date) return true;
|
|
43
|
+
const exchange = quote.listingExchangeName || quote.exchangeName;
|
|
44
|
+
const today = zonedDateKey(now, "America/New_York");
|
|
45
|
+
if (!today || !getPublishedUsEquitySession(exchange!, today) || date > today) return true;
|
|
46
|
+
const sourceDay = Date.parse(`${date}T00:00:00Z`);
|
|
47
|
+
for (let ahead = 1; ahead <= 10; ahead++) {
|
|
48
|
+
const nextDate = new Date(sourceDay + ahead * 24 * 60 * 60_000).toISOString().slice(0, 10);
|
|
49
|
+
const next = getPublishedUsEquitySession(exchange!, nextDate);
|
|
50
|
+
if (!next) return true;
|
|
51
|
+
if (next.kind === "session") return now >= next.close + NAV_PUBLICATION_ALLOWANCE_MS;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
21
56
|
export function isExtendedHoursExchange(quote: Quote): boolean {
|
|
22
57
|
return isUsListingExchange(quote.listingExchangeName || quote.exchangeName);
|
|
23
58
|
}
|
|
@@ -38,6 +73,7 @@ export function isQuoteStaleForCurrentSession(quote: Quote | null | undefined, n
|
|
|
38
73
|
if (!quote) return false;
|
|
39
74
|
if (quote.stale === true) return true;
|
|
40
75
|
if (!hasValidQuoteObservationTime(quote, now)) return true;
|
|
76
|
+
if (quote.priceObservation != null) return isDailyNavStale(quote, now);
|
|
41
77
|
if (isQuoteMissingActiveSessionPrice(quote, now)) return true;
|
|
42
78
|
|
|
43
79
|
// A tolerated future stamp belongs to the session in progress, not the next one.
|
|
@@ -12,7 +12,7 @@ import { mergeQuoteMetadata, quoteMetadataFromQuote } from "./metadata";
|
|
|
12
12
|
import { resolvePriceBasis } from "../market/price-basis";
|
|
13
13
|
import { hasLikelyQuoteUnitMismatch } from "../../utils/currency-units";
|
|
14
14
|
import { debugLog } from "../../utils/debug-log";
|
|
15
|
-
import { hasValidQuoteObservationTime, isExtendedHoursExchange, isQuoteStaleForCurrentSession } from "./freshness";
|
|
15
|
+
import { dailyNavDate, hasValidQuoteObservationTime, isExtendedHoursExchange, isQuoteStaleForCurrentSession } from "./freshness";
|
|
16
16
|
import { activeUsExtendedHoursSession, isTimestampStaleForExchangeSession } from "../market/freshness";
|
|
17
17
|
import {
|
|
18
18
|
finalizeSessionFields,
|
|
@@ -243,7 +243,10 @@ function buildAcceptedPriceCandidates(contributions: QuoteContribution[], now: n
|
|
|
243
243
|
} {
|
|
244
244
|
const ranks = new Map(contributions.map((quote) => [quote, priceRank(quote, now)] as const));
|
|
245
245
|
const sorted = [...contributions]
|
|
246
|
-
|
|
246
|
+
// Even a stale research snapshot must have its own NAV evidence. Canonical
|
|
247
|
+
// fallbacks cannot supply a missing observation time or security type.
|
|
248
|
+
.filter((quote) => Number.isFinite(quote.price)
|
|
249
|
+
&& (quote.priceObservation == null || dailyNavDate(quote, now) !== null))
|
|
247
250
|
.sort((left, right) => {
|
|
248
251
|
const rankDelta = ranks.get(left)! - ranks.get(right)!;
|
|
249
252
|
if (rankDelta !== 0) return rankDelta;
|
|
@@ -281,7 +284,7 @@ function buildAcceptedPriceCandidates(contributions: QuoteContribution[], now: n
|
|
|
281
284
|
}
|
|
282
285
|
|
|
283
286
|
export function isQuoteContributionStaleForCurrentSession(contribution: Quote, now = Date.now()): boolean {
|
|
284
|
-
if (contribution.marketState != null) return isQuoteStaleForCurrentSession(contribution, now);
|
|
287
|
+
if (contribution.priceObservation != null || contribution.marketState != null) return isQuoteStaleForCurrentSession(contribution, now);
|
|
285
288
|
// A price-only source can be combined with independent session metadata.
|
|
286
289
|
// Its observation must still belong to the current active session.
|
|
287
290
|
if (contribution.stale === true || !hasValidQuoteObservationTime(contribution, now)) return true;
|
|
@@ -387,8 +390,8 @@ export function resolveCanonicalQuote(
|
|
|
387
390
|
const { accepted: acceptedPriceCandidates, rejectedProviders } = buildAcceptedPriceCandidates(effectiveQuoteCandidates, now);
|
|
388
391
|
if (acceptedPriceCandidates.length === 0) return {};
|
|
389
392
|
|
|
390
|
-
|
|
391
|
-
|
|
393
|
+
let sessionCandidates = buildSessionCandidates(effectiveQuoteCandidates);
|
|
394
|
+
let listingCandidates = buildListingCandidates(contributions);
|
|
392
395
|
const routingCandidates = buildRoutingCandidates(contributions);
|
|
393
396
|
const descriptiveCandidates = buildDescriptiveCandidates(contributions);
|
|
394
397
|
|
|
@@ -401,9 +404,18 @@ export function resolveCanonicalQuote(
|
|
|
401
404
|
// The selected response owns the price convention; metadata enrichment must
|
|
402
405
|
// not declare units for another provider's price or supply incompatible anchors.
|
|
403
406
|
assignField(resolved, provenance, "priceBasis", priceProvider);
|
|
407
|
+
assignField(resolved, provenance, "priceObservation", priceProvider);
|
|
408
|
+
const isNav = priceProvider?.priceObservation === "nav";
|
|
409
|
+
if (isNav) {
|
|
410
|
+
sessionCandidates = priceProvider ? [priceProvider] : [];
|
|
411
|
+
listingCandidates = sessionCandidates;
|
|
412
|
+
} else {
|
|
413
|
+
sessionCandidates = sessionCandidates.filter((quote) => quote.priceObservation == null);
|
|
414
|
+
}
|
|
404
415
|
const selectedBasis = resolvePriceBasis(priceProvider?.priceBasis, priceProvider?.instrumentType);
|
|
405
416
|
const compatiblePrice = (candidate: QuoteContribution) => candidate === priceProvider
|
|
406
|
-
||
|
|
417
|
+
|| !isNav && candidate.priceObservation == null
|
|
418
|
+
&& selectedBasis !== null && resolvePriceBasis(candidate.priceBasis, candidate.instrumentType) === selectedBasis;
|
|
407
419
|
const compatiblePriceCandidates = acceptedPriceCandidates.filter(compatiblePrice);
|
|
408
420
|
for (const field of PRICE_FIELD_KEYS) {
|
|
409
421
|
if (field === "price") continue;
|
|
@@ -459,13 +471,14 @@ export function resolveCanonicalQuote(
|
|
|
459
471
|
|
|
460
472
|
let descriptiveProvider: QuoteContribution | undefined;
|
|
461
473
|
for (const field of DESCRIPTIVE_FIELD_KEYS) {
|
|
462
|
-
const
|
|
474
|
+
const ownsPriceField = field === "high52w" || field === "low52w" || isNav && (field === "volume" || field === "marketCap");
|
|
475
|
+
const provider = pickField(resolved, provenance, field, ownsPriceField ? descriptiveCandidates.filter(compatiblePrice) : descriptiveCandidates);
|
|
463
476
|
descriptiveProvider ??= provider;
|
|
464
477
|
}
|
|
465
478
|
provenance.descriptive = toProvenance(descriptiveProvider);
|
|
466
479
|
// Descriptive enrichment cannot turn an unknown-unit bond observation into
|
|
467
480
|
// an ordinary share price by replacing its source-reported security type.
|
|
468
|
-
if (priceProvider?.instrumentType?.trim().toUpperCase() === "BOND") {
|
|
481
|
+
if (isNav || priceProvider?.instrumentType?.trim().toUpperCase() === "BOND") {
|
|
469
482
|
assignField(resolved, provenance, "instrumentType", priceProvider);
|
|
470
483
|
}
|
|
471
484
|
|
|
@@ -474,6 +487,7 @@ export function resolveCanonicalQuote(
|
|
|
474
487
|
providerId: priceProvider?.providerId ?? contributions[0]!.providerId,
|
|
475
488
|
price: Number(resolved.price ?? priceProvider?.price ?? 0),
|
|
476
489
|
priceBasis: priceProvider?.priceBasis,
|
|
490
|
+
priceObservation: priceProvider?.priceObservation,
|
|
477
491
|
// The price's own source unit, like its basis; another provider's divisor does not apply.
|
|
478
492
|
...(typeof priceProvider?.providerPriceDivisor === "number" ? { providerPriceDivisor: priceProvider.providerPriceDivisor } : {}),
|
|
479
493
|
currency: String(resolved.currency ?? priceProvider?.currency ?? contributions[0]!.currency ?? ""),
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { Quote } from "../../types/financials";
|
|
2
|
+
import { getLanguage, tf } from "../../i18n";
|
|
3
|
+
import { dailyNavDate } from "./freshness";
|
|
4
|
+
|
|
5
|
+
/** The published NAV session date, independent of local time and receipt time. */
|
|
6
|
+
export function formatQuoteNavAsOf(quote: Quote | null | undefined, now = Date.now()): string | null {
|
|
7
|
+
const date = quote && dailyNavDate(quote, now);
|
|
8
|
+
if (!date) return null;
|
|
9
|
+
const sourceDate = new Date(`${date}T00:00:00Z`);
|
|
10
|
+
const dateText = new Intl.DateTimeFormat(getLanguage(), {
|
|
11
|
+
timeZone: "UTC",
|
|
12
|
+
month: "short",
|
|
13
|
+
day: "numeric",
|
|
14
|
+
...(sourceDate.getUTCFullYear() !== new Date(now).getUTCFullYear() ? { year: "numeric" as const } : {}),
|
|
15
|
+
}).format(sourceDate);
|
|
16
|
+
return tf("NAV · as of {date}", { date: dateText });
|
|
17
|
+
}
|
|
2
18
|
|
|
3
19
|
function clampQuoteTimestamp(lastUpdated: number | undefined, now = Date.now()): number | null {
|
|
4
20
|
if (typeof lastUpdated !== "number" || !Number.isFinite(lastUpdated) || lastUpdated <= 0) {
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { createPluginCache } from "../../../data/plugin-cache";
|
|
10
10
|
import { loadCloudResource } from "../shared/cloud-resource";
|
|
11
11
|
import { canonicalExchange, normalizeSymbol } from "../../../utils/exchanges";
|
|
12
|
+
import { resolveCurrencyUnit } from "../../../utils/currency-units";
|
|
12
13
|
import type { PricePoint } from "../../../types/financials";
|
|
13
14
|
import { resolveDatedReturns, type DatedReturn } from "./metrics";
|
|
14
15
|
import { qualifySharpeCadence } from "./sharpe-cadence";
|
|
@@ -18,15 +19,27 @@ export interface RiskInstrument {
|
|
|
18
19
|
symbol: string;
|
|
19
20
|
exchange: string;
|
|
20
21
|
}
|
|
22
|
+
/** A holding to load: what ranks it by value before any daily history is requested. */
|
|
23
|
+
export interface RiskHoldingRequest extends RiskInstrument {
|
|
24
|
+
/** Signed quantity; with a USD quote it ranks the holding by current value. */
|
|
25
|
+
quantity?: number;
|
|
26
|
+
/** The currency the position is recorded in, for the rate that values it when it is left out. */
|
|
27
|
+
currency?: string | null;
|
|
28
|
+
/** USD value from the broker's own snapshot, ranking a holding no USD quote values. */
|
|
29
|
+
snapshotValue?: number | null;
|
|
30
|
+
/** Outside the basket model whatever its history says (a short, a derivative): quoted, never history-requested. */
|
|
31
|
+
unsupported?: boolean;
|
|
32
|
+
}
|
|
21
33
|
interface RiskMarketHistory {
|
|
22
34
|
instrument: RiskInstrument;
|
|
23
35
|
currency: string | null;
|
|
24
36
|
quote: CloudQuotePayload | null;
|
|
25
|
-
points: CloudPricePointPayload[];
|
|
26
37
|
returns: DatedReturn[];
|
|
27
38
|
asOf: string | null;
|
|
28
39
|
/** Latest completed close, used as the mark when no current quote arrived. */
|
|
29
40
|
closeMark?: { price: number; date: string; currency: string } | null;
|
|
41
|
+
/** The listing's own price when it does not quote in USD: it values a left-out holding, never a return. */
|
|
42
|
+
listing?: { price: number; currency: string } | null;
|
|
30
43
|
error: string | null;
|
|
31
44
|
}
|
|
32
45
|
export interface RiskMarketSnapshot {
|
|
@@ -36,7 +49,22 @@ export interface RiskMarketSnapshot {
|
|
|
36
49
|
fetchedAt: string;
|
|
37
50
|
warnings: string[];
|
|
38
51
|
brokerOptions?: import("./risk-options").PortfolioOptionBook;
|
|
52
|
+
/** USD per unit of each listing or position currency of the holdings, at load time. */
|
|
53
|
+
fxRates?: Record<string, number>;
|
|
39
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The most holdings one load requests daily history for, largest current USD
|
|
57
|
+
* value first. Each holding costs one Cloud request of up to 18 months of
|
|
58
|
+
* closes, four in flight, again after every two-minute cache expiry, and the
|
|
59
|
+
* correlation view pairs every basket holding with every other. See
|
|
60
|
+
* docs/research-data.md for the measured cost behind the number.
|
|
61
|
+
*/
|
|
62
|
+
export const RISK_HISTORY_LIMIT = 150;
|
|
63
|
+
/** Holdings quoted in one load, 50 to a batch request. */
|
|
64
|
+
const RISK_QUOTE_LIMIT = 500;
|
|
65
|
+
const HISTORY_CONCURRENCY = 4;
|
|
66
|
+
const MAX_FX_CURRENCIES = 24;
|
|
67
|
+
export const BEYOND_SIZE_REASON = `Beyond the supported size: only the ${RISK_HISTORY_LIMIT} largest holdings by value are modelled`;
|
|
40
68
|
// Listing venues as the quotes report them; MTUM lists on Cboe BZX, not Arca.
|
|
41
69
|
export const RISK_FACTOR_INSTRUMENTS: RiskInstrument[] = [
|
|
42
70
|
["SPY", "ARCA"],
|
|
@@ -56,12 +84,12 @@ const message = (error: unknown) =>
|
|
|
56
84
|
export const portfolioRiskCache = createPluginCache<RiskMarketSnapshot>({
|
|
57
85
|
kind: "portfolio-risk",
|
|
58
86
|
source: "gloom-cloud",
|
|
59
|
-
schemaVersion:
|
|
87
|
+
schemaVersion: 3,
|
|
60
88
|
policy: { staleMs: 2 * 60_000, expireMs: 24 * 60 * 60_000 },
|
|
61
89
|
});
|
|
62
90
|
type RiskCloudClient = Pick<
|
|
63
91
|
typeof apiClient,
|
|
64
|
-
"getCloudHistory" | "getCloudQuotesBatch" | "getCloudFredSeries"
|
|
92
|
+
"getCloudHistory" | "getCloudQuotesBatch" | "getCloudFredSeries" | "getCloudExchangeRate"
|
|
65
93
|
>;
|
|
66
94
|
|
|
67
95
|
function validateRiskQuote(
|
|
@@ -101,7 +129,6 @@ export function validateRiskHistory(
|
|
|
101
129
|
quote: CloudQuotePayload | null,
|
|
102
130
|
now = new Date(),
|
|
103
131
|
): {
|
|
104
|
-
points: CloudPricePointPayload[];
|
|
105
132
|
returns: DatedReturn[];
|
|
106
133
|
asOf: string;
|
|
107
134
|
closeMark: { price: number; date: string; currency: string } | null;
|
|
@@ -192,7 +219,6 @@ export function validateRiskHistory(
|
|
|
192
219
|
throw new Error(cadence.reason ?? "Daily session cadence unavailable");
|
|
193
220
|
const lastPoint = points.at(-1)!;
|
|
194
221
|
return {
|
|
195
|
-
points,
|
|
196
222
|
returns: resolved.returns,
|
|
197
223
|
asOf: last,
|
|
198
224
|
// A missing or stale quote is not a current mark; the latest dated close is.
|
|
@@ -229,38 +255,69 @@ function validateFred(
|
|
|
229
255
|
return data;
|
|
230
256
|
}
|
|
231
257
|
|
|
258
|
+
/** The USD value a holding ranks by before its history loads; unknown ranks last. */
|
|
259
|
+
function rankValue(request: RiskHoldingRequest, quote: CloudQuotePayload | null): number {
|
|
260
|
+
if (quote && request.quantity != null && Number.isFinite(request.quantity))
|
|
261
|
+
return Math.abs(request.quantity * quote.price);
|
|
262
|
+
const snapshot = request.snapshotValue;
|
|
263
|
+
return snapshot != null && Number.isFinite(snapshot) ? Math.abs(snapshot) : -1;
|
|
264
|
+
}
|
|
265
|
+
const largestFirst = (
|
|
266
|
+
left: { rank: number; order: number },
|
|
267
|
+
right: { rank: number; order: number },
|
|
268
|
+
) => right.rank - left.rank || left.order - right.order;
|
|
269
|
+
|
|
232
270
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
271
|
+
* Quotes every holding, then requests daily history only for the factor
|
|
272
|
+
* proxies and the largest USD holdings that can enter the basket, up to
|
|
273
|
+
* RISK_HISTORY_LIMIT. Holdings that quote in another currency, positions the
|
|
274
|
+
* caller marks unsupported and holdings past the limit get no history request;
|
|
275
|
+
* their entry says why. The signal stops the fetch: no request starts once it
|
|
276
|
+
* aborts, the ones in flight are cancelled, and the call rejects instead of
|
|
277
|
+
* returning a partial snapshot nobody is waiting for.
|
|
236
278
|
*/
|
|
237
279
|
export async function fetchPortfolioRiskMarket(
|
|
238
|
-
|
|
280
|
+
holdings: readonly RiskHoldingRequest[],
|
|
239
281
|
client: RiskCloudClient = apiClient,
|
|
240
282
|
now = new Date(),
|
|
241
283
|
signal?: AbortSignal,
|
|
242
284
|
): Promise<RiskMarketSnapshot> {
|
|
243
285
|
signal?.throwIfAborted();
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
286
|
+
const normalized = (row: RiskInstrument): RiskInstrument => ({
|
|
287
|
+
symbol: normalizeSymbol(row.symbol),
|
|
288
|
+
exchange: canonicalExchange(row.exchange),
|
|
289
|
+
});
|
|
290
|
+
const factors = new Map(
|
|
291
|
+
RISK_FACTOR_INSTRUMENTS.map((row) => [riskInstrumentId(row), normalized(row)]),
|
|
292
|
+
);
|
|
293
|
+
const entries = new Map<
|
|
294
|
+
string,
|
|
295
|
+
{ id: string; instrument: RiskInstrument; request: RiskHoldingRequest; order: number; rank: number }
|
|
296
|
+
>();
|
|
297
|
+
for (const request of holdings) {
|
|
298
|
+
const id = riskInstrumentId(request);
|
|
299
|
+
if (!entries.has(id))
|
|
300
|
+
entries.set(id, {
|
|
301
|
+
id,
|
|
302
|
+
instrument: normalized(request),
|
|
303
|
+
request,
|
|
304
|
+
order: entries.size,
|
|
305
|
+
rank: rankValue(request, null),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const quoted = [...entries.values()].sort(largestFirst).slice(0, RISK_QUOTE_LIMIT);
|
|
309
|
+
const targets = [
|
|
310
|
+
...new Map([
|
|
311
|
+
...quoted.map((row) => [row.id, row.instrument] as const),
|
|
312
|
+
...factors,
|
|
313
|
+
]).values(),
|
|
254
314
|
];
|
|
255
|
-
if (unique.length > 87)
|
|
256
|
-
throw new Error(
|
|
257
|
-
"Portfolio risk supports at most 80 holdings plus its factor proxies",
|
|
258
|
-
);
|
|
259
315
|
const quotes = new Map<string, CloudQuotePayload>();
|
|
316
|
+
const listings = new Map<string, { price: number; currency: string }>();
|
|
260
317
|
const warnings: string[] = [];
|
|
261
|
-
for (let start = 0; start <
|
|
318
|
+
for (let start = 0; start < targets.length; start += 50) {
|
|
262
319
|
signal?.throwIfAborted();
|
|
263
|
-
const requested =
|
|
320
|
+
const requested = targets.slice(start, start + 50);
|
|
264
321
|
try {
|
|
265
322
|
const response = await client.getCloudQuotesBatch(
|
|
266
323
|
requested,
|
|
@@ -275,17 +332,25 @@ export async function fetchPortfolioRiskMarket(
|
|
|
275
332
|
canonicalExchange(item.exchange),
|
|
276
333
|
);
|
|
277
334
|
if (
|
|
278
|
-
target
|
|
279
|
-
item.status
|
|
280
|
-
item.data
|
|
281
|
-
normalizeSymbol(item.data.symbol)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
335
|
+
!target ||
|
|
336
|
+
item.status !== "success" ||
|
|
337
|
+
!item.data ||
|
|
338
|
+
normalizeSymbol(item.data.symbol) !== normalizeSymbol(target.symbol)
|
|
339
|
+
)
|
|
340
|
+
continue;
|
|
341
|
+
const id = riskInstrumentId(target);
|
|
342
|
+
// A holding quoted in another currency never enters the USD basket;
|
|
343
|
+
// its price only values what is left out.
|
|
344
|
+
if (!factors.has(id) && item.data.currency && item.data.currency !== "USD") {
|
|
345
|
+
if (Number.isFinite(item.data.price) && item.data.price > 0)
|
|
346
|
+
listings.set(id, { price: item.data.price, currency: item.data.currency });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
validateRiskQuote(item.data, target, now);
|
|
351
|
+
quotes.set(id, item.data);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
warnings.push(`${target.symbol}: ${message(error)}`);
|
|
289
354
|
}
|
|
290
355
|
}
|
|
291
356
|
} catch (error) {
|
|
@@ -293,6 +358,17 @@ export async function fetchPortfolioRiskMarket(
|
|
|
293
358
|
warnings.push(`Holding marks: ${message(error)}`);
|
|
294
359
|
}
|
|
295
360
|
}
|
|
361
|
+
const candidates = quoted
|
|
362
|
+
.filter((row) => !factors.has(row.id) && !row.request.unsupported && !listings.has(row.id))
|
|
363
|
+
.map((row) => ({ ...row, rank: rankValue(row.request, quotes.get(row.id) ?? null) }))
|
|
364
|
+
.sort(largestFirst);
|
|
365
|
+
const requestedIds = new Set([
|
|
366
|
+
...factors.keys(),
|
|
367
|
+
...candidates.slice(0, RISK_HISTORY_LIMIT).map((row) => row.id),
|
|
368
|
+
]);
|
|
369
|
+
const instruments = [
|
|
370
|
+
...new Map([...factors, ...[...entries.values()].map((row) => [row.id, row.instrument] as const)]).entries(),
|
|
371
|
+
];
|
|
296
372
|
const start = new Date(now);
|
|
297
373
|
start.setUTCMonth(start.getUTCMonth() - 18);
|
|
298
374
|
start.setUTCDate(1);
|
|
@@ -301,15 +377,36 @@ export async function fetchPortfolioRiskMarket(
|
|
|
301
377
|
endDate = new Date(Date.parse(today) - 86_400_000)
|
|
302
378
|
.toISOString()
|
|
303
379
|
.slice(0, 10);
|
|
304
|
-
const histories: RiskMarketHistory[] =
|
|
380
|
+
const histories: RiskMarketHistory[] = instruments.map(([id, instrument]) => {
|
|
381
|
+
const quote = quotes.get(id) ?? null,
|
|
382
|
+
listing = listings.get(id) ?? null;
|
|
383
|
+
return {
|
|
384
|
+
instrument,
|
|
385
|
+
quote,
|
|
386
|
+
listing,
|
|
387
|
+
currency: quote?.currency ?? listing?.currency ?? null,
|
|
388
|
+
returns: [],
|
|
389
|
+
asOf: null,
|
|
390
|
+
error: requestedIds.has(id)
|
|
391
|
+
? null
|
|
392
|
+
: listing
|
|
393
|
+
? "Current USD listing identity unavailable"
|
|
394
|
+
: entries.get(id)?.request.unsupported
|
|
395
|
+
? "Outside the long USD equity basket"
|
|
396
|
+
: BEYOND_SIZE_REASON,
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
const pending = instruments.flatMap(([id], index) =>
|
|
400
|
+
requestedIds.has(id) ? [index] : [],
|
|
401
|
+
);
|
|
305
402
|
let next = 0;
|
|
306
403
|
const historyWork = Promise.all(
|
|
307
|
-
Array.from({ length: Math.min(
|
|
308
|
-
while (next <
|
|
404
|
+
Array.from({ length: Math.min(HISTORY_CONCURRENCY, pending.length) }, async () => {
|
|
405
|
+
while (next < pending.length) {
|
|
309
406
|
signal?.throwIfAborted();
|
|
310
|
-
const index = next
|
|
311
|
-
|
|
312
|
-
quote =
|
|
407
|
+
const index = pending[next++]!,
|
|
408
|
+
entry = histories[index]!,
|
|
409
|
+
{ instrument, quote } = entry;
|
|
313
410
|
try {
|
|
314
411
|
const response = await client.getCloudHistory(
|
|
315
412
|
instrument.symbol,
|
|
@@ -332,28 +429,30 @@ export async function fetchPortfolioRiskMarket(
|
|
|
332
429
|
now,
|
|
333
430
|
);
|
|
334
431
|
histories[index] = {
|
|
335
|
-
|
|
336
|
-
quote,
|
|
432
|
+
...entry,
|
|
337
433
|
currency: quote?.currency ?? resolved.closeMark?.currency ?? null,
|
|
338
434
|
...resolved,
|
|
339
435
|
error: null,
|
|
340
436
|
};
|
|
341
437
|
} catch (error) {
|
|
342
438
|
if (isAccessDenied(error) || signal?.aborted) throw error;
|
|
343
|
-
histories[index] = {
|
|
344
|
-
instrument,
|
|
345
|
-
quote,
|
|
346
|
-
currency: quote?.currency ?? null,
|
|
347
|
-
points: [],
|
|
348
|
-
returns: [],
|
|
349
|
-
asOf: null,
|
|
350
|
-
error: message(error),
|
|
351
|
-
};
|
|
439
|
+
histories[index] = { ...entry, error: message(error) };
|
|
352
440
|
}
|
|
353
441
|
}
|
|
354
442
|
}),
|
|
355
443
|
);
|
|
356
|
-
|
|
444
|
+
// Rates value left-out holdings that do not quote in USD; they never convert a return.
|
|
445
|
+
const fxCurrencies = [
|
|
446
|
+
...new Set(
|
|
447
|
+
[...entries.values()].flatMap((row) => {
|
|
448
|
+
const currency = resolveCurrencyUnit(
|
|
449
|
+
listings.get(row.id)?.currency ?? (quotes.has(row.id) ? null : row.request.currency),
|
|
450
|
+
).currency;
|
|
451
|
+
return currency && currency !== "USD" ? [currency] : [];
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
454
|
+
].slice(0, MAX_FX_CURRENCIES);
|
|
455
|
+
const [, fred, fx] = await Promise.all([
|
|
357
456
|
historyWork,
|
|
358
457
|
Promise.allSettled(
|
|
359
458
|
["DGS10", "VIXCLS"].map(async (id) =>
|
|
@@ -368,6 +467,17 @@ export async function fetchPortfolioRiskMarket(
|
|
|
368
467
|
),
|
|
369
468
|
),
|
|
370
469
|
),
|
|
470
|
+
Promise.allSettled(
|
|
471
|
+
fxCurrencies.map(async (currency) => {
|
|
472
|
+
const response = await client.getCloudExchangeRate(currency);
|
|
473
|
+
const rate = response.status === "success" || response.status === "partial"
|
|
474
|
+
? response.data?.rate
|
|
475
|
+
: undefined;
|
|
476
|
+
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0)
|
|
477
|
+
throw new Error(response.reasonCode ?? "Rate unavailable");
|
|
478
|
+
return [currency, rate] as const;
|
|
479
|
+
}),
|
|
480
|
+
),
|
|
371
481
|
]);
|
|
372
482
|
signal?.throwIfAborted();
|
|
373
483
|
for (const [index, result] of fred.entries()) {
|
|
@@ -378,24 +488,40 @@ export async function fetchPortfolioRiskMarket(
|
|
|
378
488
|
);
|
|
379
489
|
}
|
|
380
490
|
}
|
|
491
|
+
const fxRates: Record<string, number> = {};
|
|
492
|
+
for (const [index, result] of fx.entries()) {
|
|
493
|
+
if (result.status === "fulfilled") fxRates[result.value[0]] = result.value[1];
|
|
494
|
+
else {
|
|
495
|
+
if (isAccessDenied(result.reason)) throw result.reason;
|
|
496
|
+
warnings.push(`${fxCurrencies[index]} exchange rate: ${message(result.reason)}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
381
499
|
return {
|
|
382
500
|
histories,
|
|
383
501
|
yields: fred[0]!.status === "fulfilled" ? fred[0]!.value : null,
|
|
384
502
|
volatility: fred[1]!.status === "fulfilled" ? fred[1]!.value : null,
|
|
385
503
|
fetchedAt: now.toISOString(),
|
|
386
504
|
warnings,
|
|
505
|
+
fxRates,
|
|
387
506
|
};
|
|
388
507
|
}
|
|
389
|
-
|
|
390
|
-
|
|
508
|
+
/** What changes the requests: identity, size and whether a holding can enter the basket. */
|
|
509
|
+
const cacheKey = (holdings: readonly RiskHoldingRequest[]) =>
|
|
510
|
+
[
|
|
511
|
+
...new Set(
|
|
512
|
+
holdings.map(
|
|
513
|
+
(row) => `${riskInstrumentId(row)}|${row.quantity ?? ""}|${row.currency ?? ""}|${row.unsupported ? 1 : 0}`,
|
|
514
|
+
),
|
|
515
|
+
),
|
|
516
|
+
].sort().join(",");
|
|
391
517
|
export async function loadPortfolioRiskMarket(
|
|
392
|
-
|
|
518
|
+
holdings: readonly RiskHoldingRequest[],
|
|
393
519
|
force = false,
|
|
394
520
|
) {
|
|
395
521
|
const { payload, stale, refreshError } = await loadCloudResource(
|
|
396
522
|
portfolioRiskCache,
|
|
397
|
-
cacheKey(
|
|
398
|
-
() => fetchPortfolioRiskMarket(
|
|
523
|
+
cacheKey(holdings),
|
|
524
|
+
() => fetchPortfolioRiskMarket(holdings),
|
|
399
525
|
{ force },
|
|
400
526
|
);
|
|
401
527
|
return {
|