gloomberb 0.18.0 → 0.18.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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/market-movers.ts +7 -0
  3. package/src/api-client/types.ts +9 -0
  4. package/src/cli/commands/fred-series.ts +15 -5
  5. package/src/cli/commands/market.ts +9 -5
  6. package/src/cli/commands/overview.ts +31 -6
  7. package/src/cli/commands/ticker.ts +5 -2
  8. package/src/cli/pane-functions/function-help.ts +4 -4
  9. package/src/cli/symbol-list.ts +51 -16
  10. package/src/components/chart/composite/composite-chart.tsx +11 -5
  11. package/src/components/chart/composite/panel-surface.tsx +6 -8
  12. package/src/components/chart/composite/price-axis-labels.tsx +2 -1
  13. package/src/components/chart/composite/types.ts +4 -0
  14. package/src/components/command-bar/workflow/collection-actions.ts +4 -0
  15. package/src/components/onboarding/wizard-positions.ts +8 -0
  16. package/src/docs/snapshot.json +52 -17
  17. package/src/plugins/builtin/analytics/index.tsx +1 -1
  18. package/src/plugins/builtin/analytics/risk-client.ts +44 -11
  19. package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
  20. package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
  21. package/src/plugins/builtin/analytics/risk-model.ts +79 -16
  22. package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
  23. package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
  24. package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
  25. package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
  26. package/src/plugins/builtin/custom-view/loader.ts +3 -2
  27. package/src/plugins/builtin/custom-view/pane.tsx +5 -4
  28. package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
  29. package/src/plugins/builtin/econ/calendar-model.ts +3 -2
  30. package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
  31. package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
  32. package/src/plugins/builtin/executives/data.ts +24 -8
  33. package/src/plugins/builtin/executives/index.tsx +2 -18
  34. package/src/plugins/builtin/executives/pane.tsx +34 -10
  35. package/src/plugins/builtin/market-movers/client.ts +2 -2
  36. package/src/plugins/builtin/market-movers/index.tsx +0 -3
  37. package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
  38. package/src/plugins/builtin/market-movers/session.ts +25 -10
  39. package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
  40. package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
  41. package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
  42. package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
  43. package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
  44. package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
  45. package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
  46. package/src/plugins/builtin/notes/cli.ts +33 -8
  47. package/src/plugins/builtin/notes/export.ts +49 -3
  48. package/src/plugins/builtin/notes/files.ts +14 -3
  49. package/src/plugins/builtin/notes/index.tsx +3 -3
  50. package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
  51. package/src/plugins/builtin/notes/store.ts +13 -3
  52. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
  53. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
  54. package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
  55. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
  56. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  57. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
  58. package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
  59. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
  60. package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
  61. package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
  62. package/src/plugins/builtin/thirteenf/api.ts +4 -1
  63. package/src/plugins/builtin/thirteenf/data.ts +5 -1
  64. package/src/plugins/builtin/thirteenf/headless.ts +3 -1
  65. package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
  66. package/src/portfolio/cost-fx.ts +84 -0
  67. package/src/portfolio/mutations.ts +16 -2
  68. package/src/portfolio/position-metrics.ts +45 -4
  69. package/src/portfolio/position-quantity.ts +43 -0
  70. package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
  71. package/src/sync/core-contributors.ts +1 -0
  72. package/src/team/collections.ts +2 -0
  73. package/src/tickers/metadata.ts +10 -0
  74. package/src/types/ticker.ts +9 -0
  75. package/src/utils/currency-units.ts +9 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "format": 1,
3
3
  "site": "https://gloom.sh",
4
- "updatedAt": "2026-10-16",
4
+ "updatedAt": "2026-10-17",
5
5
  "categories": [
6
6
  {
7
7
  "id": "research",
@@ -159,7 +159,7 @@
159
159
  "code": "DDIS",
160
160
  "name": "Debt maturities",
161
161
  "description": "Principal due by year",
162
- "detail": "Principal due in the next 12 months, in each of years two to five, and after, from the latest annual filing, with the near-term share ranked against 10 years. History tracks principal and interest expense.",
162
+ "detail": "Principal due in the next 12 months, in each of years two to five, and after, from the latest annual filing, with the near-term share ranked against 10 years. A newer 10-Q adds its current and non-current debt. History tracks principal and interest expense.",
163
163
  "category": "research",
164
164
  "arg": "ORCL",
165
165
  "shot": "https://gloom.sh/screenshots/fn/DDIS.png",
@@ -759,7 +759,7 @@
759
759
  "code": "CRYP",
760
760
  "name": "Crypto board",
761
761
  "description": "Top coins by market cap",
762
- "detail": "The top 100 coins by market cap, stablecoins on their own tab, with returns from a day to a year, a 30-day sparkline, volume, and market cap. Prices stream live on every plan where the market allows.",
762
+ "detail": "The top 100 coins by market cap, with returns from a day to a year, a 30-day sparkline, volume, and market cap. Stablecoins have their own tab: total supply over time stacked by coin, and each coin's supply, 7- and 30-day supply growth and share of the total. Prices stream live on every plan where the market allows.",
763
763
  "category": "markets",
764
764
  "shot": "https://gloom.sh/screenshots/fn/CRYP.png",
765
765
  "lesson": "/academy/crypto-and-prediction-markets/crypto-from-zero"
@@ -777,8 +777,8 @@
777
777
  {
778
778
  "code": "FUT",
779
779
  "name": "Futures board",
780
- "description": "Most active futures",
781
- "detail": "Each root's most active contract across equity index, rates, energy, metals, agriculture, and FX with last price, unit and session change, grouped by sector, naming the nearer month while it still trades.",
780
+ "description": "Front-month futures",
781
+ "detail": "Each root's front contract until its stated roll date across equity index, rates, energy, metals, agriculture, and FX with last price, unit and session change, grouped by sector, naming the day's most active month by volume when it is another one.",
782
782
  "category": "markets",
783
783
  "shot": "https://gloom.sh/screenshots/fn/FUT.png",
784
784
  "lesson": "/academy/futures-commodities-energy/futures-from-zero"
@@ -1186,7 +1186,7 @@
1186
1186
  "code": "CDS",
1187
1187
  "name": "Single-name CDS",
1188
1188
  "description": "Corporate 5Y CDS and trades",
1189
- "detail": "Single-name corporate CDS. CDS alone lists the issuers traded in the last 5 days with trade count, last trade and 5Y spread, to rank them by; with a ticker or issuer, its 5Y spread (level, date, 1M change, range) and its trades.",
1189
+ "detail": "Single-name corporate CDS. CDS alone lists the issuers traded in the last 5 days with trade count, last trade, 5Y spread and its 1W and 1M moves through the roll while the level is current (points upfront past 2,000 bp), ranked with --sort spread, 1w or 1m; with a ticker or issuer, its 5Y spread (level, date, 1W and 1M change, range), its trades and its daily 5Y levels (--csv --section daily exports them).",
1190
1190
  "category": "macro",
1191
1191
  "arg": "F",
1192
1192
  "shot": "https://gloom.sh/screenshots/fn/CDS.png",
@@ -1229,8 +1229,8 @@
1229
1229
  {
1230
1230
  "code": "TRACE",
1231
1231
  "name": "Corporate bond tape",
1232
- "description": "Latest corporate and agency sales",
1233
- "detail": "Latest corporate and agency bond sales: issuer, coupon, price, yield, and grade. TRACE alone lists the tape; TRACE with an issuer filters to that name. It opens the Bonds section of Fixed Income.",
1232
+ "description": "Corporate and agency sales by issuer or CUSIP",
1233
+ "detail": "Latest corporate and agency bond sales: issuer, CUSIP, coupon, maturity, price, yield, and grade. TRACE alone lists the tape; TRACE with an issuer name, a bond symbol root, a stock ticker, or a CUSIP finds that issuer's outstanding bonds. It opens the Bonds section of Fixed Income.",
1234
1234
  "category": "macro",
1235
1235
  "arg": "issuer"
1236
1236
  },
@@ -1299,8 +1299,8 @@
1299
1299
  {
1300
1300
  "code": "INS",
1301
1301
  "name": "Insider transactions",
1302
- "description": "Form 4 activity and insider buying",
1303
- "detail": "Insider buys, sells, awards, and gifts from Form 4 filings with insider name, size, and price. Alone it ranks the companies whose insiders are buying in the market, with why: clusters, repeat buys, size against their usual. US equities only.",
1302
+ "description": "Form 4 activity, insider buying and selling",
1303
+ "detail": "Insider buys, sells, awards, and gifts from Form 4 filings with insider name, size, and price. Alone it ranks the companies whose insiders are buying, or selling, in the market, with why: clusters, repeat trades, size against their usual, the stake added or sold. Filters by role, size, cluster, new or exited positions and market cap. US equities only.",
1304
1304
  "category": "ownership",
1305
1305
  "arg": "NVDA",
1306
1306
  "shot": "https://gloom.sh/screenshots/fn/INS.png",
@@ -2407,9 +2407,14 @@
2407
2407
  "drift",
2408
2408
  "rebalance",
2409
2409
  "weights",
2410
- "glossary"
2410
+ "glossary",
2411
+ "dividend income",
2412
+ "yield",
2413
+ "DRIP",
2414
+ "reinvest",
2415
+ "DVD"
2411
2416
  ],
2412
- "updatedAt": "2026-10-12",
2417
+ "updatedAt": "2026-10-17",
2413
2418
  "blocks": [
2414
2419
  {
2415
2420
  "type": "heading",
@@ -2493,6 +2498,31 @@
2493
2498
  }
2494
2499
  ]
2495
2500
  },
2501
+ {
2502
+ "type": "heading",
2503
+ "level": 2,
2504
+ "text": "Dividend income"
2505
+ },
2506
+ {
2507
+ "type": "paragraph",
2508
+ "text": "`gloomberb portfolio show Core` also lists what each share or fund pays a year: its trailing twelve months of dividends per share times the shares you hold, in the portfolio's currency, and that as a yield on the holding's value. The TOTAL row gives the portfolio's income and its yield on the whole portfolio, cash included. A holding whose dividends could not be read shows `—`, and so does the total, never $0."
2509
+ },
2510
+ {
2511
+ "type": "paragraph",
2512
+ "text": "To see what reinvesting them adds, ask for a DRIP projection:"
2513
+ },
2514
+ {
2515
+ "type": "code",
2516
+ "code": "gloomberb portfolio show Core --drip --years 3 --withholding 15"
2517
+ },
2518
+ {
2519
+ "type": "paragraph",
2520
+ "text": "Each year the shares you hold pay today's dividend, the withholding comes off, and the rest buys shares at today's price. The table gives each holding's shares now and after the last year, and the income each year, net of withholding. `--years` takes 1 to 5."
2521
+ },
2522
+ {
2523
+ "type": "paragraph",
2524
+ "text": "To compare payers before you buy, rank them by yield with payout and growth: `gloomberb fn DVD KO PEP JNJ`."
2525
+ },
2496
2526
  {
2497
2527
  "type": "details",
2498
2528
  "summary": "Fine print",
@@ -2501,6 +2531,7 @@
2501
2531
  "type": "list",
2502
2532
  "items": [
2503
2533
  "Targets need not add up to 100%; the report says what is unallocated.",
2534
+ "Income uses trailing dividends, not announced ones. DRIP holds dividends, prices and exchange rates where they are today and reinvests once a year; it is an illustration, not a forecast.",
2504
2535
  "Trades use the current price and leave out fees, taxes and lot sizes.",
2505
2536
  "A broker portfolio uses the cash its account reports.",
2506
2537
  "In the PF pane the columns read `WEIGHT`, `TGT WT`, `DRIFT` and `TRADE`."
@@ -2510,7 +2541,7 @@
2510
2541
  },
2511
2542
  {
2512
2543
  "type": "paragraph",
2513
- "text": "Related: [PF](/docs/functions#pf), [CLI](/docs/cli-functions#portfolios-and-notes)."
2544
+ "text": "Related: [PF](/docs/functions#pf), [CLI](/docs/cli-functions#portfolios-and-notes). Methodology: [dividend income and DRIP](https://github.com/gloom-sh/gloomberb/blob/main/docs/research-data.md#portfolio-income-and-drip)."
2514
2545
  }
2515
2546
  ]
2516
2547
  },
@@ -5273,6 +5304,10 @@
5273
5304
  "`financials`, `fundamentals`, `valuation`, `events`, `analyst` `<symbol>`",
5274
5305
  "Revenue and EPS, profile, multiples, dividends and splits, ratings and targets"
5275
5306
  ],
5307
+ [
5308
+ "`fn DVD <symbols>`",
5309
+ "Dividend yield and history; several symbols rank in one table by yield, with payout, growth, last ex-date and pay date"
5310
+ ],
5276
5311
  [
5277
5312
  "`holders`, `insider`, `13f`, `filings` `<symbol>`",
5278
5313
  "Holders, insiders, institutions and funds, SEC filings"
@@ -5335,7 +5370,7 @@
5335
5370
  "rows": [
5336
5371
  [
5337
5372
  "`portfolio list|show|create|delete <name>`",
5338
- "Manual portfolios; `show` lists value, weight, target, drift and the trade to rebalance, and `show --explain` adds what those terms mean"
5373
+ "Manual portfolios; `show` lists value, weight, a year of dividend income and its yield, target, drift and the trade to rebalance; `show --explain` adds what those terms mean, and `show --drip [--years 1-5] [--withholding <pct>]` projects reinvested dividends"
5339
5374
  ],
5340
5375
  [
5341
5376
  "`portfolio add|remove <portfolio> <symbols...> [--file <csv|txt>]`",
@@ -5453,7 +5488,7 @@
5453
5488
  },
5454
5489
  {
5455
5490
  "type": "paragraph",
5456
- "text": "`gloomberb catalog <function>` lists a function's options. `shot` also takes `--tab <id>`."
5491
+ "text": "`gloomberb catalog <function>` lists a function's options. `shot` also takes `--tab <id>`. `shot` draws a function's options as its pane does: `gloomberb shot 13F 0001067983 --compare SPY` opens the fund's overlap with SPY. An option only the text report reads, such as `--limit`, fails `shot` with exit 1 and names the `fn` command that reads it, instead of saving the pane without it."
5457
5492
  },
5458
5493
  {
5459
5494
  "type": "paragraph",
@@ -7546,7 +7581,7 @@
7546
7581
  {
7547
7582
  "url": "/academy/futures-commodities-energy/futures-from-zero",
7548
7583
  "title": "Futures from zero (FUT)",
7549
- "summary": "A futures contract is a standard, exchange-traded promise to buy or sell a set quantity on a set month at a price agreed today, settled with daily margin, and FUT shows the most active months across markets.",
7584
+ "summary": "A futures contract is a standard, exchange-traded promise to buy or sell a set quantity on a set month at a price agreed today, settled with daily margin, and FUT shows each market's front month.",
7550
7585
  "chapter": "Futures, commodities and energy",
7551
7586
  "minutes": 5
7552
7587
  },
@@ -7646,7 +7681,7 @@
7646
7681
  "title": "Credit default swaps (CDS, CDX, SOVR)",
7647
7682
  "summary": "A credit default swap is insurance on a borrower's default, and CDS, CDX and SOVR show reported trades on companies, the main indices and 5-year country spreads.",
7648
7683
  "chapter": "Rates, bonds and credit",
7649
- "minutes": 4
7684
+ "minutes": 5
7650
7685
  },
7651
7686
  {
7652
7687
  "url": "/academy/rates-bonds-credit/credit/debt-and-covenants",
@@ -482,7 +482,7 @@ export const portfolioAnalyticsModule: PluginModule = {
482
482
  paneId: "analytics",
483
483
  label: prefix === "PORT" ? "Portfolio Analytics" : "Portfolio Market Risk",
484
484
  description: "Benchmark-relative risk, factor betas, stress shifts and local account evidence.",
485
- keywords: ["risk", "analytics", "sharpe", "beta", "sector", "allocation", "portfolio"],
485
+ keywords: ["risk", "analytics", "sharpe", "beta", "sector", "allocation", "portfolio", "style", "quality", "factor tilt"],
486
486
  shortcut: { prefix, argKind: "text", argOptional: true, argPlaceholder: "portfolio-id" },
487
487
  headless: portfolioRiskHeadless,
488
488
  canCreate: (context) => context.config.portfolios.length > 0,
@@ -16,12 +16,15 @@ import { qualifySharpeCadence } from "./sharpe-cadence";
16
16
  import { evidenceDay } from "./risk-evidence";
17
17
  import { FX_LIVE_RATE_MAX_DEVIATION } from "../../../market-data/coordinator/fx-legs";
18
18
  import {
19
+ accountFxPair,
20
+ accountFxReason,
19
21
  convertClosesToUsd,
20
22
  FX_DISAGREES,
21
23
  FX_NO_PAIR,
22
24
  FX_UNAVAILABLE,
23
25
  riskConversion,
24
26
  validateFxHistory,
27
+ type AccountFx,
25
28
  type FxCloses,
26
29
  type RiskConversion,
27
30
  } from "./risk-fx";
@@ -65,8 +68,10 @@ export interface RiskMarketSnapshot {
65
68
  fetchedAt: string;
66
69
  warnings: string[];
67
70
  brokerOptions?: import("./risk-options").PortfolioOptionBook;
68
- /** USD per unit of each listing or position currency of the holdings, at load time. */
71
+ /** USD per unit of the account currency and of each listing or position currency of the holdings, at load time. */
69
72
  fxRates?: Record<string, number>;
73
+ /** For an account not in USD: the daily closes its returns convert at, or why they cannot. */
74
+ accountFx?: AccountFx;
70
75
  }
71
76
  /**
72
77
  * The most holdings one load requests daily history for, largest current USD
@@ -81,13 +86,14 @@ const RISK_QUOTE_LIMIT = 500;
81
86
  const HISTORY_CONCURRENCY = 4;
82
87
  const MAX_FX_CURRENCIES = 24;
83
88
  export const BEYOND_SIZE_REASON = `Beyond the supported size: only the ${RISK_HISTORY_LIMIT} largest holdings by value are modelled`;
84
- // Listing venues as the quotes report them; MTUM lists on Cboe BZX, not Arca.
89
+ // Listing venues as the quotes report them; MTUM and QUAL list on Cboe BZX, not Arca.
85
90
  export const RISK_FACTOR_INSTRUMENTS: RiskInstrument[] = [
86
91
  ["SPY", "ARCA"],
87
92
  ["IWM", "ARCA"],
88
93
  ["IWD", "ARCA"],
89
94
  ["IWF", "ARCA"],
90
95
  ["MTUM", "BATS"],
96
+ ["QUAL", "BATS"],
91
97
  ["IEF", "ARCA"],
92
98
  ["HYG", "ARCA"],
93
99
  ].map(([symbol, exchange]) => ({ symbol: symbol!, exchange: exchange! }));
@@ -313,7 +319,9 @@ const largestFirst = (
313
319
  * competes at its value at the current rate and, when requested, also loads
314
320
  * its currency's daily FX history once per currency, so its closes convert to
315
321
  * USD. Holdings with no FX pair, positions the caller marks unsupported and
316
- * holdings past the limit get no history request; their entry says why. The
322
+ * holdings past the limit get no history request; their entry says why. An
323
+ * account in another currency than USD also loads that currency's current
324
+ * rate and daily FX history, which the model converts every return with. The
317
325
  * signal stops the fetch: no request starts once it
318
326
  * aborts, the ones in flight are cancelled, and the call rejects instead of
319
327
  * returning a partial snapshot nobody is waiting for.
@@ -323,6 +331,7 @@ export async function fetchPortfolioRiskMarket(
323
331
  client: RiskCloudClient = apiClient,
324
332
  now = new Date(),
325
333
  signal?: AbortSignal,
334
+ accountCurrency = "USD",
326
335
  ): Promise<RiskMarketSnapshot> {
327
336
  signal?.throwIfAborted();
328
337
  const normalized = (row: RiskInstrument): RiskInstrument => ({
@@ -417,14 +426,16 @@ export async function fetchPortfolioRiskMarket(
417
426
  }
418
427
  }
419
428
  // Current rates value a foreign holding: its rank, its mark, and its size when left out.
429
+ // The account currency's comes first: it values every holding.
430
+ const account = accountCurrency === "USD" ? null : accountCurrency;
420
431
  const fxCurrencies = [
421
432
  ...new Set(
422
- [...entries.values()].flatMap((row) => {
433
+ [...(account ? [account] : []), ...[...entries.values()].flatMap((row) => {
423
434
  const currency = resolveCurrencyUnit(
424
435
  listings.get(row.id)?.currency ?? (quotes.has(row.id) ? null : row.request.currency),
425
436
  ).currency;
426
437
  return currency && currency !== "USD" ? [currency] : [];
427
- }),
438
+ })],
428
439
  ),
429
440
  ].slice(0, MAX_FX_CURRENCIES);
430
441
  const fx = await Promise.allSettled(
@@ -539,6 +550,22 @@ export async function fetchPortfolioRiskMarket(
539
550
  }
540
551
  return loading;
541
552
  };
553
+ // The account currency's daily closes, shared with any listing that converts from it.
554
+ const accountWork: Promise<AccountFx | undefined> = (async () => {
555
+ if (!account) return undefined;
556
+ const failed = (error: string): AccountFx =>
557
+ ({ currency: account, pair: accountFxPair(account), closes: {}, first: null, asOf: null, error });
558
+ const conversion = riskConversion(account);
559
+ if (!conversion || conversion.divisor !== 1) return failed(accountFxReason(account, FX_NO_PAIR));
560
+ try {
561
+ const closes = await fxHistory(conversion);
562
+ return { currency: account, pair: accountFxPair(account), closes: Object.fromEntries(closes.closes),
563
+ first: closes.first, asOf: closes.asOf, error: null };
564
+ } catch (error) {
565
+ if (isAccessDenied(error) || signal?.aborted) throw error;
566
+ return failed(accountFxReason(account, message(error)));
567
+ }
568
+ })();
542
569
  const pending = instruments.flatMap(([id], index) =>
543
570
  requestedIds.has(id) ? [index] : [],
544
571
  );
@@ -596,7 +623,7 @@ export async function fetchPortfolioRiskMarket(
596
623
  }
597
624
  }),
598
625
  );
599
- const [, fred] = await Promise.all([
626
+ const [, fred, accountFx] = await Promise.all([
600
627
  historyWork,
601
628
  Promise.allSettled(
602
629
  ["DGS10", "VIXCLS"].map(async (id) =>
@@ -611,6 +638,7 @@ export async function fetchPortfolioRiskMarket(
611
638
  ),
612
639
  ),
613
640
  ),
641
+ accountWork,
614
642
  ]);
615
643
  signal?.throwIfAborted();
616
644
  for (const [index, result] of fred.entries()) {
@@ -628,25 +656,30 @@ export async function fetchPortfolioRiskMarket(
628
656
  fetchedAt: now.toISOString(),
629
657
  warnings,
630
658
  fxRates,
659
+ ...(accountFx ? { accountFx } : {}),
631
660
  };
632
661
  }
633
- /** What changes the requests: identity, size and whether a holding can enter the basket. */
634
- const cacheKey = (holdings: readonly RiskHoldingRequest[]) =>
662
+ /**
663
+ * What changes the requests: identity, size, whether a holding can enter the
664
+ * basket, and an account currency other than USD (whose daily FX the load adds).
665
+ */
666
+ const cacheKey = (holdings: readonly RiskHoldingRequest[], accountCurrency: string) =>
635
667
  [
636
668
  ...new Set(
637
669
  holdings.map(
638
670
  (row) => `${riskInstrumentId(row)}|${row.quantity ?? ""}|${row.currency ?? ""}|${row.unsupported ? 1 : 0}`,
639
671
  ),
640
672
  ),
641
- ].sort().join(",");
673
+ ].sort().join(",") + (accountCurrency === "USD" ? "" : `@${accountCurrency}`);
642
674
  export async function loadPortfolioRiskMarket(
643
675
  holdings: readonly RiskHoldingRequest[],
644
676
  force = false,
677
+ accountCurrency = "USD",
645
678
  ) {
646
679
  const { payload, stale, refreshError } = await loadCloudResource(
647
680
  portfolioRiskCache,
648
- cacheKey(holdings),
649
- () => fetchPortfolioRiskMarket(holdings),
681
+ cacheKey(holdings, accountCurrency),
682
+ () => fetchPortfolioRiskMarket(holdings, undefined, undefined, undefined, accountCurrency),
650
683
  { force },
651
684
  );
652
685
  return {
@@ -4,9 +4,26 @@ import { dailyFxCloses, FxHistoryError, type FxCloses } from "../../../market-da
4
4
  import { getPublishedUsEquityCalendarDay } from "../../../market-data/published-us-sessions";
5
5
  import type { PricePoint } from "../../../types/financials";
6
6
  import { resolveCurrencyUnit } from "../../../utils/currency-units";
7
+ import type { DatedReturn } from "./metrics";
7
8
 
8
9
  export type { FxCloses };
9
10
 
11
+ /**
12
+ * The daily closes that restate a non-USD account's returns, as the market
13
+ * load keeps them (plain data, so the cache can hold them), or why there are none.
14
+ */
15
+ export interface AccountFx {
16
+ currency: string;
17
+ /** "USD/TRY" or "EUR/USD", as the pair trades; null when no daily pair prices the currency. */
18
+ pair: string | null;
19
+ /** USD per unit of the account currency at each completed daily close; empty with an error. */
20
+ closes: Record<string, number>;
21
+ first: string | null;
22
+ asOf: string | null;
23
+ /** Why returns cannot convert into the account currency; every holding is left out with it. */
24
+ error: string | null;
25
+ }
26
+
10
27
  /** How a listing quoted in another currency is restated in USD. */
11
28
  export interface RiskConversion {
12
29
  /** The listing currency as quoted, sub-units included (GBp, ZAc). */
@@ -35,6 +52,50 @@ const MAX_LOCAL_CLOSED_DAYS = 10;
35
52
  const BASKET_CALENDAR = "NYSE";
36
53
  const DAY_MS = 86_400_000;
37
54
 
55
+ /** The daily pair of an account currency as dealers quote it ("USD/TRY", "EUR/USD"), or null when none prices it. */
56
+ export function accountFxPair(currency: string): string | null {
57
+ const leg = fxLegForCurrency(currency);
58
+ return !leg ? null : leg.invert ? `USD/${leg.currency}` : `${leg.currency}/USD`;
59
+ }
60
+
61
+ /**
62
+ * Why the account currency's daily closes cannot convert a holding: the
63
+ * problem a listing's own conversion would report (stale, unavailable,
64
+ * gapped, disagreeing with the current rate), said of the account's pair.
65
+ */
66
+ export function accountFxReason(currency: string, problem: string): string {
67
+ const pair = accountFxPair(currency);
68
+ if (!pair) return `No daily FX pair converts USD into the account currency ${currency}`;
69
+ const tail = /^Foreign holdings: (daily FX .*)$/.exec(problem)?.[1];
70
+ return `${pair} ${tail ?? "daily FX closes unavailable"}`;
71
+ }
72
+
73
+ /**
74
+ * Restates daily USD returns in the account currency at the FX closes of each
75
+ * return's own start and end dates: the holding's USD value is divided by the
76
+ * USD price of one account unit on both dates, so a return becomes
77
+ * (1 + r) * fx(start) / fx(end) - 1. A listing already converted from its own
78
+ * currency to USD therefore converts at the cross of the two closes of the
79
+ * same date. Returns before the first FX close or after the last are dropped,
80
+ * so the series starts and ends with the FX history; a return inside it with an
81
+ * endpoint that has no FX close rejects the whole series instead of bridging it.
82
+ */
83
+ export function convertReturnsToAccount(
84
+ returns: readonly DatedReturn[],
85
+ fx: Pick<AccountFx, "currency" | "closes" | "first" | "asOf">,
86
+ ): DatedReturn[] {
87
+ if (!fx.first || !fx.asOf) throw new Error(accountFxReason(fx.currency, FX_UNAVAILABLE));
88
+ const converted: DatedReturn[] = [];
89
+ for (const point of returns) {
90
+ if (point.startDateKey < fx.first || point.dateKey > fx.asOf) continue;
91
+ const start = fx.closes[point.startDateKey],
92
+ end = fx.closes[point.dateKey];
93
+ if (!(start! > 0) || !(end! > 0)) throw new Error(accountFxReason(fx.currency, FX_GAP));
94
+ converted.push({ ...point, value: (1 + point.value) * (start! / end!) - 1 });
95
+ }
96
+ return converted;
97
+ }
98
+
38
99
  /** The pair that restates a listing currency in USD, or null when there is none. */
39
100
  export function riskConversion(listingCurrency: string): RiskConversion | null {
40
101
  const unit = resolveCurrencyUnit(listingCurrency);
@@ -15,6 +15,7 @@ import {
15
15
  RISK_VIEWS,
16
16
  riskEvidence,
17
17
  riskPercentile,
18
+ riskReturnsCurrency,
18
19
  riskValue,
19
20
  sinceFallbackNote,
20
21
  sinceFiledMethod,
@@ -142,7 +143,7 @@ export const portfolioRiskHeadless: HeadlessPaneDefinition<"bundle"> = {
142
143
  "Explicit local evidence for account returns and attribution",
143
144
  ],
144
145
  limitations: [
145
- `Fixed-current-weight USD equity basket over the holdings that qualify, largest ${RISK_HISTORY_LIMIT} by value; foreign listings convert to USD at daily FX closes; metadata.coverage states the covered share of market value and each holding left out.`,
146
+ `Fixed-current-weight equity basket over the holdings that qualify, largest ${RISK_HISTORY_LIMIT} by value, in the account's currency; foreign listings convert to USD at daily FX closes, and a non-USD account's returns then convert into its currency at its own daily FX closes; metadata.coverage states the covered share of market value and each holding left out.`,
146
147
  "--since filed buys the 13F's own weights at the filing-date close and holds them (--weights current holds today's weights fixed instead).",
147
148
  "No account performance inferred.",
148
149
  "ETF price-return factor proxies.",
@@ -246,6 +247,7 @@ export const portfolioRiskHeadless: HeadlessPaneDefinition<"bundle"> = {
246
247
  ctx.apiClient,
247
248
  new Date(),
248
249
  ctx.signal,
250
+ local.portfolio.currency,
249
251
  ),
250
252
  localOnly
251
253
  ? Promise.resolve(undefined)
@@ -270,7 +272,12 @@ export const portfolioRiskHeadless: HeadlessPaneDefinition<"bundle"> = {
270
272
  // Options for views the report does not print are named, not dropped silently; the fallback from --since opens the report.
271
273
  const fallback = sinceFallbackNote(local.portfolio);
272
274
  const shifted = ["equity-shift", "rate-shift", "vol-shift"].filter((key) => args.options[key] != null);
275
+ // A non-USD account's returns were converted, and the basket views say into what.
276
+ const returnsCurrency = riskReturnsCurrency(model);
273
277
  const notices = [
278
+ ...(returnsCurrency && (!requestedView || (BASKET_VIEWS.has(requestedView) && requestedView !== "holdings"))
279
+ ? [`${returnsCurrency[0]!.toUpperCase()}${returnsCurrency.slice(1)}.`]
280
+ : []),
274
281
  ...(model.window.kind === "since-filed" ? [sinceFiledMethod(model.window, model.benchmark)] : []),
275
282
  ...(args.options.since === "filed" && model.window.kind !== "since-filed" ? [fallback] : []),
276
283
  ...(requestedView && requestedView !== "stress" && shifted.length ? [`${shifted.map((key) => `--${key}`).join(", ")} change${shifted.length === 1 ? "s" : ""} the stress view only, not --view ${requestedView}`] : []),
@@ -335,6 +342,8 @@ export const portfolioRiskHeadless: HeadlessPaneDefinition<"bundle"> = {
335
342
  ...(basketView && model.coverage
336
343
  ? { coverage: coverageSummary(model.coverage, COMPACT_LEFT_OUT_LIMIT) }
337
344
  : {}),
345
+ // The currency a non-USD account's returns and amounts are in, and the daily pair they converted at.
346
+ ...(basketView && model.accountConversion ? { accountConversion: model.accountConversion } : {}),
338
347
  asOf: model.fetchedAt,
339
348
  ...(notices.length ? { notices } : {}),
340
349
  },