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.
- package/package.json +1 -1
- package/src/api-client/market-movers.ts +7 -0
- package/src/api-client/types.ts +9 -0
- package/src/cli/commands/fred-series.ts +15 -5
- package/src/cli/commands/market.ts +9 -5
- package/src/cli/commands/overview.ts +31 -6
- package/src/cli/commands/ticker.ts +5 -2
- package/src/cli/pane-functions/function-help.ts +4 -4
- package/src/cli/symbol-list.ts +51 -16
- package/src/components/chart/composite/composite-chart.tsx +11 -5
- package/src/components/chart/composite/panel-surface.tsx +6 -8
- package/src/components/chart/composite/price-axis-labels.tsx +2 -1
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/command-bar/workflow/collection-actions.ts +4 -0
- package/src/components/onboarding/wizard-positions.ts +8 -0
- package/src/docs/snapshot.json +52 -17
- package/src/plugins/builtin/analytics/index.tsx +1 -1
- package/src/plugins/builtin/analytics/risk-client.ts +44 -11
- package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
- package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
- package/src/plugins/builtin/analytics/risk-model.ts +79 -16
- package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
- package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
- package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
- package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
- package/src/plugins/builtin/custom-view/loader.ts +3 -2
- package/src/plugins/builtin/custom-view/pane.tsx +5 -4
- package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
- package/src/plugins/builtin/econ/calendar-model.ts +3 -2
- package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
- package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
- package/src/plugins/builtin/executives/data.ts +24 -8
- package/src/plugins/builtin/executives/index.tsx +2 -18
- package/src/plugins/builtin/executives/pane.tsx +34 -10
- package/src/plugins/builtin/market-movers/client.ts +2 -2
- package/src/plugins/builtin/market-movers/index.tsx +0 -3
- package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
- package/src/plugins/builtin/market-movers/session.ts +25 -10
- package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
- package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
- package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
- package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
- package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
- package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
- package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
- package/src/plugins/builtin/notes/cli.ts +33 -8
- package/src/plugins/builtin/notes/export.ts +49 -3
- package/src/plugins/builtin/notes/files.ts +14 -3
- package/src/plugins/builtin/notes/index.tsx +3 -3
- package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
- package/src/plugins/builtin/notes/store.ts +13 -3
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
- package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
- package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
- package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
- package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
- package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
- package/src/plugins/builtin/thirteenf/api.ts +4 -1
- package/src/plugins/builtin/thirteenf/data.ts +5 -1
- package/src/plugins/builtin/thirteenf/headless.ts +3 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
- package/src/portfolio/cost-fx.ts +84 -0
- package/src/portfolio/mutations.ts +16 -2
- package/src/portfolio/position-metrics.ts +45 -4
- package/src/portfolio/position-quantity.ts +43 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
- package/src/sync/core-contributors.ts +1 -0
- package/src/team/collections.ts +2 -0
- package/src/tickers/metadata.ts +10 -0
- package/src/types/ticker.ts +9 -0
- package/src/utils/currency-units.ts +9 -0
package/src/docs/snapshot.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": 1,
|
|
3
3
|
"site": "https://gloom.sh",
|
|
4
|
-
"updatedAt": "2026-10-
|
|
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,
|
|
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": "
|
|
781
|
-
"detail": "Each root's
|
|
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
|
|
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": "
|
|
1233
|
-
"detail": "Latest corporate and agency bond sales: issuer, coupon, price, yield, and grade. TRACE alone lists the tape; TRACE with an issuer
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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":
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
634
|
-
|
|
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
|
|
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
|
},
|