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.
Files changed (167) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/data.ts +18 -3
  3. package/src/api-client/index.ts +3 -0
  4. package/src/api-client/members.ts +26 -0
  5. package/src/api-client/perps.ts +4 -0
  6. package/src/api-client/types.ts +7 -1
  7. package/src/app/global-shortcuts.ts +14 -6
  8. package/src/app/keybindings/actions.ts +1 -1
  9. package/src/app/runtime/update.ts +38 -2
  10. package/src/app.tsx +10 -6
  11. package/src/cli/pane-functions/function-help.ts +9 -2
  12. package/src/components/command-bar/commands/collection.ts +5 -1
  13. package/src/components/command-bar/routes/root/command-items.ts +9 -2
  14. package/src/components/command-bar/routes/root/plugin-install.ts +2 -2
  15. package/src/components/command-bar/routes/root/provider-search.ts +19 -5
  16. package/src/components/command-bar/routes/root/runtime.ts +14 -5
  17. package/src/components/command-bar/routes/ticker-search/results.ts +31 -5
  18. package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
  19. package/src/components/command-bar/routing/actions.ts +2 -0
  20. package/src/components/command-bar/selection-runtime.ts +38 -0
  21. package/src/components/command-bar/surface/index.tsx +7 -2
  22. package/src/components/index.ts +2 -2
  23. package/src/components/layout/floating-pane.tsx +6 -0
  24. package/src/components/layout/header.tsx +24 -7
  25. package/src/components/layout/missing-pane.tsx +2 -2
  26. package/src/components/layout/pane/header.tsx +63 -30
  27. package/src/components/layout/pane/index.tsx +6 -0
  28. package/src/components/layout/pane/title.ts +31 -7
  29. package/src/components/layout/shell/drag/index.ts +6 -5
  30. package/src/components/layout/shell/drag/runtime.ts +3 -0
  31. package/src/components/layout/shell/index.tsx +6 -0
  32. package/src/components/layout/shell/pane/layers.tsx +15 -7
  33. package/src/components/layout/shell/terminal-pointer-runtime.ts +24 -3
  34. package/src/components/layout/shell/ticker-link-menu.ts +23 -9
  35. package/src/components/metric-treemap/heat-surface.tsx +718 -0
  36. package/src/components/metric-treemap/heat-tiles.ts +210 -0
  37. package/src/components/metric-treemap/index.tsx +2 -0
  38. package/src/components/metric-treemap/layout.ts +6 -0
  39. package/src/components/metric-treemap/scene.ts +432 -0
  40. package/src/components/pane-settings-dialog/field-dialogs.tsx +1 -0
  41. package/src/components/toggle-list.tsx +38 -12
  42. package/src/components/ui/country-flag-art.tsx +449 -0
  43. package/src/components/ui/country-flag.tsx +41 -0
  44. package/src/components/ui/data-table/types.ts +13 -0
  45. package/src/components/ui/icon.tsx +2 -0
  46. package/src/components/ui/index.ts +4 -0
  47. package/src/components/ui/multi-select/dialog.tsx +108 -22
  48. package/src/components/ui/range-track.tsx +46 -0
  49. package/src/components/ui/tabs.tsx +4 -0
  50. package/src/core/state/app/types.ts +10 -1
  51. package/src/i18n/es.ts +5 -3
  52. package/src/i18n/ja.ts +5 -3
  53. package/src/i18n/ko.ts +5 -3
  54. package/src/i18n/zh-cn.ts +5 -3
  55. package/src/i18n/zh-tw.ts +5 -3
  56. package/src/layout/ticker-navigation.ts +28 -2
  57. package/src/market-data/quotes/contributions.ts +6 -0
  58. package/src/market-data/quotes/freshness.ts +36 -0
  59. package/src/market-data/quotes/resolution.ts +22 -8
  60. package/src/market-data/quotes/time.ts +16 -0
  61. package/src/plugins/builtin/analytics/risk-client.ts +185 -59
  62. package/src/plugins/builtin/analytics/risk-headless.ts +55 -49
  63. package/src/plugins/builtin/analytics/risk-math.ts +33 -13
  64. package/src/plugins/builtin/analytics/risk-model.ts +250 -60
  65. package/src/plugins/builtin/analytics/risk-pane.tsx +61 -30
  66. package/src/plugins/builtin/analytics/risk-test-data.ts +182 -0
  67. package/src/plugins/builtin/buildout/cells.tsx +15 -12
  68. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  69. package/src/plugins/builtin/buildout/detail/index.tsx +0 -1
  70. package/src/plugins/builtin/buildout/detail/intel.tsx +6 -6
  71. package/src/plugins/builtin/buildout/detail/site.tsx +4 -7
  72. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -19
  73. package/src/plugins/builtin/catalysts/index.tsx +2 -2
  74. package/src/plugins/builtin/catalysts/pane.tsx +3 -1
  75. package/src/plugins/builtin/cds/index.tsx +1 -0
  76. package/src/plugins/builtin/debt-maturities/wall-bar.tsx +14 -41
  77. package/src/plugins/builtin/dividend-yield/client.ts +4 -2
  78. package/src/plugins/builtin/earnings/client.ts +2 -2
  79. package/src/plugins/builtin/earnings-calls/index.tsx +1 -0
  80. package/src/plugins/builtin/fx-matrix/direction.ts +81 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +71 -33
  82. package/src/plugins/builtin/fx-matrix/live-legs.ts +17 -0
  83. package/src/plugins/builtin/fx-matrix/pairs.ts +68 -14
  84. package/src/plugins/builtin/fx-matrix/settings.ts +62 -0
  85. package/src/plugins/builtin/jobs/index.tsx +1 -0
  86. package/src/plugins/builtin/jobs/share-bars.tsx +5 -28
  87. package/src/plugins/builtin/market-heatmap/data.ts +28 -2
  88. package/src/plugins/builtin/market-heatmap/index.tsx +250 -79
  89. package/src/plugins/builtin/market-heatmap/live.ts +88 -0
  90. package/src/plugins/builtin/market-heatmap/portfolio.ts +27 -7
  91. package/src/plugins/builtin/market-movers/client.ts +3 -2
  92. package/src/plugins/builtin/market-movers/headless.ts +8 -1
  93. package/src/plugins/builtin/market-movers/screener.ts +18 -5
  94. package/src/plugins/builtin/members/client.ts +36 -0
  95. package/src/plugins/builtin/members/headless.ts +33 -0
  96. package/src/plugins/builtin/members/index.tsx +20 -0
  97. package/src/plugins/builtin/members/model.ts +60 -0
  98. package/src/plugins/builtin/members/pane.tsx +168 -0
  99. package/src/plugins/builtin/mna/index.tsx +2 -1
  100. package/src/plugins/builtin/mna/pane.tsx +5 -2
  101. package/src/plugins/builtin/options/index.tsx +2 -1
  102. package/src/plugins/builtin/pe-band/client.ts +29 -2
  103. package/src/plugins/builtin/pe-band/headless.ts +2 -2
  104. package/src/plugins/builtin/pe-band/model.ts +11 -7
  105. package/src/plugins/builtin/pe-band/pane.tsx +2 -1
  106. package/src/plugins/builtin/pe-band/report-dates.ts +78 -0
  107. package/src/plugins/builtin/perps/client.ts +17 -3
  108. package/src/plugins/builtin/perps/equity-identity.ts +16 -0
  109. package/src/plugins/builtin/perps/equity-row.tsx +6 -4
  110. package/src/plugins/builtin/plugin-marketplace/activation.ts +19 -0
  111. package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -2
  112. package/src/plugins/builtin/plugin-marketplace/store.ts +2 -0
  113. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +43 -2
  114. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +48 -5
  115. package/src/plugins/builtin/scanner/hilo-bars.tsx +12 -70
  116. package/src/plugins/builtin/scanner/hilo-model.ts +0 -18
  117. package/src/plugins/builtin/sectors/move-bar.tsx +10 -32
  118. package/src/plugins/builtin/social-mentions/index.tsx +1 -1
  119. package/src/plugins/builtin/thirteenf/index.tsx +2 -1
  120. package/src/plugins/builtin/ticker-detail/overview/components.tsx +5 -60
  121. package/src/plugins/builtin/ticker-detail/overview/pane.tsx +1 -1
  122. package/src/plugins/builtin/ticker-detail/pane.tsx +1 -1
  123. package/src/plugins/builtin/ticker-detail/quote-footer.ts +7 -1
  124. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +20 -4
  125. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +2 -2
  126. package/src/plugins/builtin/vol-surface/index.tsx +1 -1
  127. package/src/plugins/catalog-browser.ts +2 -0
  128. package/src/plugins/catalog-manifest.ts +1 -0
  129. package/src/plugins/catalog-ui.ts +3 -1
  130. package/src/plugins/catalog.ts +1 -1
  131. package/src/plugins/host-link.ts +33 -1
  132. package/src/plugins/installer.ts +48 -12
  133. package/src/plugins/loader.ts +18 -3
  134. package/src/plugins/manager-node.ts +15 -5
  135. package/src/plugins/registry/index.ts +1 -1
  136. package/src/plugins/registry/pane-settings.ts +25 -7
  137. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  138. package/src/renderers/dom/data-table/row.tsx +50 -16
  139. package/src/renderers/dom/desktop/icons.tsx +1 -0
  140. package/src/renderers/dom/host/box.tsx +69 -26
  141. package/src/renderers/dom/host/tabs.tsx +37 -1
  142. package/src/renderers/dom/styles.css +40 -5
  143. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +11 -6
  144. package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
  145. package/src/renderers/electrobun/bun/desktop/update.ts +181 -87
  146. package/src/renderers/electrobun/bun/index.ts +73 -6
  147. package/src/renderers/electrobun/bun/window/remembered-frame.ts +122 -0
  148. package/src/renderers/electrobun/shared/protocol.ts +3 -0
  149. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +2 -1
  150. package/src/renderers/electrobun/view/main.tsx +7 -1
  151. package/src/renderers/electrobun/view/renderer-window-error.ts +18 -0
  152. package/src/renderers/electrobun/view/update-host.ts +45 -21
  153. package/src/sources/gloomberb-cloud/normalizers.ts +9 -2
  154. package/src/sources/provider-router/financials.ts +3 -1
  155. package/src/telemetry/benign-window-error.ts +7 -0
  156. package/src/telemetry/crash-reports-dom.ts +2 -0
  157. package/src/test-support/daily-nav.ts +25 -0
  158. package/src/theme/heat-colors.ts +174 -1
  159. package/src/tickers/instrument-kind.ts +34 -7
  160. package/src/tickers/search/asset-classes.ts +24 -9
  161. package/src/tickers/search/index.ts +44 -11
  162. package/src/tickers/search/ranking.ts +23 -3
  163. package/src/types/financials.ts +2 -0
  164. package/src/types/plugin.ts +18 -5
  165. package/src/ui/context-menu.tsx +1 -0
  166. package/src/ui/host.tsx +2 -0
  167. 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
- .filter((quote) => Number.isFinite(quote.price))
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
- const sessionCandidates = buildSessionCandidates(effectiveQuoteCandidates);
391
- const listingCandidates = buildListingCandidates(contributions);
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
- || selectedBasis !== null && resolvePriceBasis(candidate.priceBasis, candidate.instrumentType) === selectedBasis;
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 provider = pickField(resolved, provenance, field, field === "high52w" || field === "low52w" ? descriptiveCandidates.filter(compatiblePrice) : descriptiveCandidates);
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: 2,
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
- * The signal stops the fetch: no request starts once it aborts, the ones in
234
- * flight are cancelled, and the call rejects instead of returning a partial
235
- * snapshot nobody is waiting for.
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
- instruments: readonly RiskInstrument[],
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 unique = [
245
- ...new Map(
246
- [...instruments, ...RISK_FACTOR_INSTRUMENTS].map((row) => [
247
- riskInstrumentId(row),
248
- {
249
- symbol: normalizeSymbol(row.symbol),
250
- exchange: canonicalExchange(row.exchange),
251
- },
252
- ]),
253
- ).values(),
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 < unique.length; start += 50) {
318
+ for (let start = 0; start < targets.length; start += 50) {
262
319
  signal?.throwIfAborted();
263
- const requested = unique.slice(start, start + 50);
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 === "success" &&
280
- item.data &&
281
- normalizeSymbol(item.data.symbol) === normalizeSymbol(target.symbol)
282
- ) {
283
- try {
284
- validateRiskQuote(item.data, target, now);
285
- quotes.set(riskInstrumentId(target), item.data);
286
- } catch (error) {
287
- warnings.push(`${target.symbol}: ${message(error)}`);
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[] = new Array(unique.length);
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(4, unique.length) }, async () => {
308
- while (next < unique.length) {
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
- instrument = unique[index]!,
312
- quote = quotes.get(riskInstrumentId(instrument)) ?? null;
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
- instrument,
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
- const [, fred] = await Promise.all([
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
- const cacheKey = (instruments: readonly RiskInstrument[]) =>
390
- [...new Set(instruments.map(riskInstrumentId))].sort().join(",");
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
- instruments: readonly RiskInstrument[],
518
+ holdings: readonly RiskHoldingRequest[],
393
519
  force = false,
394
520
  ) {
395
521
  const { payload, stale, refreshError } = await loadCloudResource(
396
522
  portfolioRiskCache,
397
- cacheKey(instruments),
398
- () => fetchPortfolioRiskMarket(instruments),
523
+ cacheKey(holdings),
524
+ () => fetchPortfolioRiskMarket(holdings),
399
525
  { force },
400
526
  );
401
527
  return {