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
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type RiskHoldingRequest,
|
|
37
37
|
type RiskMarketSnapshot,
|
|
38
38
|
} from "./risk-client";
|
|
39
|
+
import { accountFxPair, accountFxReason, convertReturnsToAccount, FX_UNAVAILABLE, type AccountFx } from "./risk-fx";
|
|
39
40
|
|
|
40
41
|
interface PortfolioRiskHolding {
|
|
41
42
|
id: string;
|
|
@@ -43,7 +44,10 @@ interface PortfolioRiskHolding {
|
|
|
43
44
|
exchange: string;
|
|
44
45
|
quantity: number;
|
|
45
46
|
currency: string;
|
|
46
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Signed value at the current USD mark, in the account currency at its
|
|
49
|
+
* current rate; only these marks weight the basket.
|
|
50
|
+
*/
|
|
47
51
|
value: number | null;
|
|
48
52
|
/** Weight in the covered basket, null outside it. */
|
|
49
53
|
weight: number | null;
|
|
@@ -59,6 +63,7 @@ interface PortfolioRiskHolding {
|
|
|
59
63
|
/** The listing currency its closes were converted from at daily FX closes; null for a USD listing. */
|
|
60
64
|
convertedFrom: string | null;
|
|
61
65
|
historyAsOf: string | null;
|
|
66
|
+
/** Daily returns in the account currency. */
|
|
62
67
|
returns: DatedReturn[];
|
|
63
68
|
error: string | null;
|
|
64
69
|
/** Why the holding is outside the basket; null when it qualifies. */
|
|
@@ -325,6 +330,24 @@ function outsideValue(
|
|
|
325
330
|
const value = resolvePortfolioMarketValue(metrics, unit != null && Number.isFinite(unit) ? unit : null)?.gross;
|
|
326
331
|
return value != null && Number.isFinite(value) ? value : null;
|
|
327
332
|
}
|
|
333
|
+
/** USD returns in the account currency, or none with the reason its daily closes cannot convert them. */
|
|
334
|
+
function accountReturns(returns: readonly DatedReturn[], account: AccountFx): { returns: DatedReturn[]; error: string | null } {
|
|
335
|
+
if (account.error) return { returns: [], error: account.error };
|
|
336
|
+
try {
|
|
337
|
+
return { returns: convertReturnsToAccount(returns, account), error: null };
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return { returns: [], error: error instanceof Error ? error.message : accountFxReason(account.currency, FX_UNAVAILABLE) };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* "returns in TRY at daily USD/TRY closes": the currency the basket views of a
|
|
344
|
+
* non-USD account are in and how they got there; null for a USD account, or
|
|
345
|
+
* when its daily closes were unavailable and nothing converted.
|
|
346
|
+
*/
|
|
347
|
+
export function riskReturnsCurrency(model: Pick<PortfolioRiskModel, "accountConversion"> | null | undefined): string | null {
|
|
348
|
+
const conversion = model?.accountConversion;
|
|
349
|
+
return conversion ? `returns in ${conversion.currency} at daily ${conversion.pair} closes` : null;
|
|
350
|
+
}
|
|
328
351
|
// The epsilon keeps 29 of 100 at 29%, not the 28 that 0.29 * 100 rounds down to.
|
|
329
352
|
const coveragePercent = (share: number) => Math.floor(share * 100 + 1e-9);
|
|
330
353
|
const holdingCount = (count: number) => `${count} holding${count === 1 ? "" : "s"}`;
|
|
@@ -441,7 +464,27 @@ export function buildPortfolioRisk(
|
|
|
441
464
|
const byId = new Map(
|
|
442
465
|
market.histories.map((row) => [riskInstrumentId(row.instrument), row]),
|
|
443
466
|
);
|
|
444
|
-
|
|
467
|
+
// A non-USD account converts every USD return at its currency's daily closes and every USD mark at its current rate.
|
|
468
|
+
const account: AccountFx | null =
|
|
469
|
+
portfolio.currency === "USD"
|
|
470
|
+
? null
|
|
471
|
+
: market.accountFx?.currency === portfolio.currency
|
|
472
|
+
? market.accountFx
|
|
473
|
+
: {
|
|
474
|
+
currency: portfolio.currency,
|
|
475
|
+
pair: accountFxPair(portfolio.currency),
|
|
476
|
+
closes: {},
|
|
477
|
+
first: null,
|
|
478
|
+
asOf: null,
|
|
479
|
+
error: accountFxReason(portfolio.currency, FX_UNAVAILABLE),
|
|
480
|
+
};
|
|
481
|
+
// Without a current rate the latest daily close values the account.
|
|
482
|
+
const latestAccountClose = account?.asOf ? account.closes[account.asOf] : undefined;
|
|
483
|
+
const rates: Readonly<Record<string, number>> =
|
|
484
|
+
account && !((market.fxRates?.[account.currency] ?? 0) > 0) && latestAccountClose
|
|
485
|
+
? { ...market.fxRates, [account.currency]: latestAccountClose }
|
|
486
|
+
: (market.fxRates ?? {});
|
|
487
|
+
const accountRate = account ? rates[account.currency] : undefined;
|
|
445
488
|
const holdings: PortfolioRiskHolding[] = portfolioRiskTickers(
|
|
446
489
|
tickers,
|
|
447
490
|
portfolio.id,
|
|
@@ -471,14 +514,11 @@ export function buildPortfolioRisk(
|
|
|
471
514
|
const listingCurrency =
|
|
472
515
|
quote?.currency ?? close?.currency ?? history?.listing?.currency ?? ticker.metadata.currency;
|
|
473
516
|
const foreign = !convertedFrom && listingCurrency !== "USD";
|
|
474
|
-
const
|
|
517
|
+
const usdError =
|
|
475
518
|
syntheticPositionUnsupportedReason(ticker, "USD", portfolio.id) ??
|
|
476
519
|
(lots.some((row) => !Number.isFinite(row.shares))
|
|
477
520
|
? "Invalid position quantity"
|
|
478
521
|
: null) ??
|
|
479
|
-
(portfolio.currency !== "USD"
|
|
480
|
-
? "Historical account-currency FX is required"
|
|
481
|
-
: null) ??
|
|
482
522
|
(ticker.metadata.currency &&
|
|
483
523
|
(convertedFrom ?? mark?.currency) &&
|
|
484
524
|
ticker.metadata.currency !== (convertedFrom ?? mark?.currency)
|
|
@@ -490,10 +530,13 @@ export function buildPortfolioRisk(
|
|
|
490
530
|
: null) ??
|
|
491
531
|
history?.error ??
|
|
492
532
|
(!history ? "Daily history unavailable" : null);
|
|
533
|
+
// A holding with a USD history converts into the account currency, or is left out with the reason it cannot.
|
|
534
|
+
const inAccount = account && !usdError ? accountReturns(history?.returns ?? [], account) : null;
|
|
535
|
+
const error = usdError ?? inAccount?.error ?? null;
|
|
493
536
|
const priceDate = close ? close.date : regularClose ? regularClose.date : quoteDate(quote?.lastUpdated);
|
|
494
537
|
const marked =
|
|
495
538
|
mark &&
|
|
496
|
-
mark.currency ===
|
|
539
|
+
mark.currency === "USD" &&
|
|
497
540
|
!mark.stale &&
|
|
498
541
|
Number.isFinite(mark.price) &&
|
|
499
542
|
mark.price > 0 &&
|
|
@@ -506,8 +549,9 @@ export function buildPortfolioRisk(
|
|
|
506
549
|
error?.startsWith("Short positions"))
|
|
507
550
|
? quantity * mark.price
|
|
508
551
|
: null;
|
|
509
|
-
const
|
|
510
|
-
const
|
|
552
|
+
const accountValue = marked == null || !account ? marked : accountRate! > 0 ? marked / accountRate! : null;
|
|
553
|
+
const value = accountValue != null && Number.isFinite(accountValue) ? accountValue : null;
|
|
554
|
+
const returns = inAccount?.returns ?? history?.returns ?? [];
|
|
511
555
|
const leftOut =
|
|
512
556
|
error ??
|
|
513
557
|
(value == null || value <= 0
|
|
@@ -529,7 +573,8 @@ export function buildPortfolioRisk(
|
|
|
529
573
|
priceAsOf: priceDate,
|
|
530
574
|
markSource: close ? "close" : regularClose ? "regular-close" : quote ? "quote" : null,
|
|
531
575
|
convertedFrom,
|
|
532
|
-
|
|
576
|
+
// Converted returns end with the account currency's last daily close.
|
|
577
|
+
historyAsOf: (inAccount ? inAccount.returns.at(-1)?.dateKey : null) ?? history?.asOf ?? null,
|
|
533
578
|
returns,
|
|
534
579
|
error,
|
|
535
580
|
leftOut,
|
|
@@ -610,7 +655,14 @@ export function buildPortfolioRisk(
|
|
|
610
655
|
);
|
|
611
656
|
return (instrument && byId.get(riskInstrumentId(instrument))?.returns) || [];
|
|
612
657
|
};
|
|
613
|
-
|
|
658
|
+
// SPY's USD returns are the market factor and the equity stress; the risk
|
|
659
|
+
// rows compare the basket with SPY in the basket's own currency. A session
|
|
660
|
+
// without an account close is a gap in that benchmark, never bridged, so it
|
|
661
|
+
// cannot blank the holdings the closes do cover.
|
|
662
|
+
const spy = proxy(RISK_BENCHMARK);
|
|
663
|
+
const benchmark = account
|
|
664
|
+
? accountReturns(spy.filter((point) => account.closes[point.startDateKey] && account.closes[point.dateKey]), account).returns
|
|
665
|
+
: spy;
|
|
614
666
|
// Since the filing only for a portfolio that has one; any other keeps the trailing window and says why.
|
|
615
667
|
const filed = window.since === "filed" ? portfolio.importedFrom?.filed ?? null : null;
|
|
616
668
|
if (window.since === "filed" && !filed)
|
|
@@ -647,12 +699,13 @@ export function buildPortfolioRisk(
|
|
|
647
699
|
const metrics = filed
|
|
648
700
|
? sinceBasketRisk(sample, filed, RISK_BENCHMARK)
|
|
649
701
|
: rollingBasketRisk(sample, undefined, RISK_BENCHMARK);
|
|
702
|
+
// Factor proxies stay in USD: a beta is the account-currency move per unit of the factor as it trades.
|
|
650
703
|
const factors = [
|
|
651
|
-
{ id: "market", label: "Market (SPY)", series:
|
|
704
|
+
{ id: "market", label: "Market (SPY)", series: spy },
|
|
652
705
|
{
|
|
653
706
|
id: "size",
|
|
654
707
|
label: "Size (IWM - SPY)",
|
|
655
|
-
series: subtractReturns(proxy("IWM"),
|
|
708
|
+
series: subtractReturns(proxy("IWM"), spy),
|
|
656
709
|
},
|
|
657
710
|
{
|
|
658
711
|
id: "value",
|
|
@@ -662,7 +715,12 @@ export function buildPortfolioRisk(
|
|
|
662
715
|
{
|
|
663
716
|
id: "momentum",
|
|
664
717
|
label: "Momentum (MTUM - SPY)",
|
|
665
|
-
series: subtractReturns(proxy("MTUM"),
|
|
718
|
+
series: subtractReturns(proxy("MTUM"), spy),
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
id: "quality",
|
|
722
|
+
label: "Quality (QUAL - SPY)",
|
|
723
|
+
series: subtractReturns(proxy("QUAL"), spy),
|
|
666
724
|
},
|
|
667
725
|
{ id: "rates", label: "Rates (IEF)", series: proxy("IEF") },
|
|
668
726
|
{
|
|
@@ -690,7 +748,7 @@ export function buildPortfolioRisk(
|
|
|
690
748
|
{
|
|
691
749
|
id: "equity",
|
|
692
750
|
label: `Index ${shifts.equity > 0 ? "+" : ""}${shifts.equity}%`,
|
|
693
|
-
series:
|
|
751
|
+
series: spy,
|
|
694
752
|
shock: shifts.equity / 100,
|
|
695
753
|
source: "SPY price return",
|
|
696
754
|
},
|
|
@@ -772,7 +830,7 @@ export function buildPortfolioRisk(
|
|
|
772
830
|
asOf: row.priceAsOf,
|
|
773
831
|
detail: row.leftOut
|
|
774
832
|
? row.leftOut
|
|
775
|
-
: `${row.quantity} shares; ${formatNumber(row.value ?? undefined)} ${
|
|
833
|
+
: `${row.quantity} shares; ${formatNumber(row.value ?? undefined)} ${portfolio.currency}${row.markSource === "close" || row.markSource === "regular-close" ? ` at ${row.priceAsOf} close` : ""}${row.convertedFrom ? ` from ${row.convertedFrom} at daily FX` : ""}; history ${row.historyAsOf}`,
|
|
776
834
|
...(row.leftOut ? { leftOut: row.leftOut } : {}),
|
|
777
835
|
})),
|
|
778
836
|
correlation: correlation.map((row) => ({
|
|
@@ -947,6 +1005,11 @@ export function buildPortfolioRisk(
|
|
|
947
1005
|
notes,
|
|
948
1006
|
fetchedAt: market.fetchedAt,
|
|
949
1007
|
benchmark: RISK_BENCHMARK,
|
|
1008
|
+
/** The currency a non-USD account's returns converted into, at the daily closes of `pair`; null for USD. */
|
|
1009
|
+
accountConversion:
|
|
1010
|
+
account && !account.error && account.pair
|
|
1011
|
+
? { currency: account.currency, pair: account.pair, asOf: account.asOf }
|
|
1012
|
+
: null,
|
|
950
1013
|
window: filed
|
|
951
1014
|
? {
|
|
952
1015
|
kind: "since-filed" as const,
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
riskPercentile,
|
|
59
59
|
riskAmountBase,
|
|
60
60
|
riskEvidence,
|
|
61
|
+
riskReturnsCurrency,
|
|
61
62
|
riskValue,
|
|
62
63
|
RISK_VIEWS,
|
|
63
64
|
type PortfolioRiskModel,
|
|
@@ -301,7 +302,7 @@ export function PortfolioRiskPane({ focused, width, height }: PaneProps) {
|
|
|
301
302
|
const loader = useCallback(
|
|
302
303
|
async (force: boolean) => {
|
|
303
304
|
const [market, brokerOptions] = await Promise.all([
|
|
304
|
-
loadPortfolioRiskMarket(requests, force),
|
|
305
|
+
loadPortfolioRiskMarket(requests, force, portfolio!.currency),
|
|
305
306
|
loadPortfolioOptionBook(localTickers, portfolio!),
|
|
306
307
|
]);
|
|
307
308
|
return { ...market, brokerOptions };
|
|
@@ -466,6 +467,9 @@ export function PortfolioRiskPane({ focused, width, height }: PaneProps) {
|
|
|
466
467
|
// What the basket views leave out, unless the body already says why they show nothing.
|
|
467
468
|
const coverageText = model && BASKET_VIEWS.has(view) && !basketBody ? riskCoverageHeadline(model.coverage) : null;
|
|
468
469
|
const asOfDate = model?.rows[view].find((row) => row.asOf)?.asOf?.slice(0, 10) ?? null;
|
|
470
|
+
// The views estimated from returns name the currency a non-USD account's returns were converted into.
|
|
471
|
+
const returnsCurrency = BASKET_VIEWS.has(view) && view !== "holdings" ? riskReturnsCurrency(model) : null;
|
|
472
|
+
const amountBase = rows.map(riskAmountBase).find(Boolean);
|
|
469
473
|
// Dated evidence only when a row has a date; a missing date is not a claim about history.
|
|
470
474
|
const asOfText = !model
|
|
471
475
|
? null
|
|
@@ -473,7 +477,9 @@ export function PortfolioRiskPane({ focused, width, height }: PaneProps) {
|
|
|
473
477
|
...(basketBody ? [] : [
|
|
474
478
|
...(asOfDate && sharedEvidence ? [sharedEvidence] : []),
|
|
475
479
|
// The value the tail losses in money are taken on names the currency too.
|
|
476
|
-
|
|
480
|
+
...(amountBase
|
|
481
|
+
? [amountBase, ...(returnsCurrency ? [returnsCurrency] : [])]
|
|
482
|
+
: [returnsCurrency ?? model.portfolio.currency]),
|
|
477
483
|
...(asOfDate ? [asOfDate] : []),
|
|
478
484
|
...(EVIDENCE_VIEWS.has(view) && model.evidence ? [model.evidence.source] : []),
|
|
479
485
|
]),
|
|
@@ -76,6 +76,9 @@ const BROKER_FX_RATES: Readonly<Record<string, number>> = {
|
|
|
76
76
|
HKD: 0.128,
|
|
77
77
|
JPY: 0.0068,
|
|
78
78
|
TWD: 0.031,
|
|
79
|
+
// An account currency, and a listing currency with no daily FX pair.
|
|
80
|
+
TRY: 0.0202,
|
|
81
|
+
SAR: 0.2667,
|
|
79
82
|
};
|
|
80
83
|
export const BROKER_PORTFOLIO: Portfolio = { id: "broker", name: "Broker account", currency: "USD" };
|
|
81
84
|
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
TeamSentInvitation,
|
|
6
6
|
TeamSummary,
|
|
7
7
|
} from "../../../../api-client";
|
|
8
|
-
import { Button } from "../../../../components";
|
|
8
|
+
import { Button, Notice } from "../../../../components";
|
|
9
9
|
import { colors } from "../../../../theme/colors";
|
|
10
10
|
import { Box, Text, TextAttributes } from "../../../../ui";
|
|
11
11
|
import { WallTeaser } from "../../shared/wall-teaser";
|
|
@@ -122,6 +122,8 @@ export function InvitesSection({
|
|
|
122
122
|
team,
|
|
123
123
|
invitations,
|
|
124
124
|
links,
|
|
125
|
+
invitationsError,
|
|
126
|
+
linksError,
|
|
125
127
|
username,
|
|
126
128
|
width,
|
|
127
129
|
busy,
|
|
@@ -135,6 +137,9 @@ export function InvitesSection({
|
|
|
135
137
|
team: TeamSummary;
|
|
136
138
|
invitations: readonly TeamSentInvitation[];
|
|
137
139
|
links: readonly TeamInviteLink[];
|
|
140
|
+
/** Set when that list did not load: it shows the error, not an empty list. */
|
|
141
|
+
invitationsError: string | null;
|
|
142
|
+
linksError: string | null;
|
|
138
143
|
username: string;
|
|
139
144
|
width: number;
|
|
140
145
|
busy: string | null;
|
|
@@ -170,7 +175,8 @@ export function InvitesSection({
|
|
|
170
175
|
|
|
171
176
|
{manage ? (
|
|
172
177
|
<Box flexDirection="column" gap={1}>
|
|
173
|
-
<SectionTitle detail={invitations.length === 0 ? "none pending" : undefined}>Pending</SectionTitle>
|
|
178
|
+
<SectionTitle detail={invitations.length === 0 && !invitationsError ? "none pending" : undefined}>Pending</SectionTitle>
|
|
179
|
+
{invitationsError ? <Notice tone="negative">{invitationsError}</Notice> : null}
|
|
174
180
|
{invitations.map((invitation) => (
|
|
175
181
|
<AccentRow key={invitation.id} accent={colors.textMuted} width={width}>
|
|
176
182
|
<Text width={18} fg={colors.text}>
|
|
@@ -191,9 +197,10 @@ export function InvitesSection({
|
|
|
191
197
|
) : null}
|
|
192
198
|
|
|
193
199
|
<Box flexDirection="column" gap={1}>
|
|
194
|
-
<SectionTitle detail={canLink ? `${links.length} of 10 active` : undefined}>Invite links</SectionTitle>
|
|
200
|
+
<SectionTitle detail={canLink && !linksError ? `${links.length} of 10 active` : undefined}>Invite links</SectionTitle>
|
|
195
201
|
{canLink ? (
|
|
196
202
|
<>
|
|
203
|
+
{linksError ? <Notice tone="negative">{linksError}</Notice> : null}
|
|
197
204
|
{links.map((link) => {
|
|
198
205
|
const uses = link.maxUses === null ? `${link.uses} uses` : `${link.uses}/${link.maxUses} uses`;
|
|
199
206
|
return (
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type TeamSummary,
|
|
10
10
|
} from "../../../../api-client";
|
|
11
11
|
import { ApiRequestError } from "../../../../api-client/errors";
|
|
12
|
-
import { QueryBar, loadingText, useFieldRing, usePaneFooter, usePaneMenuItems, usePaneTabs, type PaneFooterSegment, type PaneHint } from "../../../../components";
|
|
12
|
+
import { QueryBar, loadingText, unavailableText, useFieldRing, usePaneFooter, usePaneMenuItems, usePaneTabs, type PaneFooterSegment, type PaneHint } from "../../../../components";
|
|
13
13
|
import { useShortcut } from "../../../../react/input";
|
|
14
14
|
import { colors } from "../../../../theme/colors";
|
|
15
15
|
import type { PaneProps } from "../../../../types/plugin";
|
|
@@ -91,11 +91,25 @@ interface TeamDetails {
|
|
|
91
91
|
members: TeamMember[];
|
|
92
92
|
invitations: TeamSentInvitation[];
|
|
93
93
|
links: TeamInviteLink[];
|
|
94
|
+
/** Why the sent invitations or the invite links did not load; the section says so instead of listing none. */
|
|
95
|
+
invitationsError: string | null;
|
|
96
|
+
linksError: string | null;
|
|
94
97
|
loading: boolean;
|
|
95
98
|
error: string | null;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
const EMPTY_DETAILS: TeamDetails = {
|
|
101
|
+
const EMPTY_DETAILS: TeamDetails = {
|
|
102
|
+
members: [], invitations: [], links: [], invitationsError: null, linksError: null, loading: false, error: null,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** A list the team pane loads beside the members: its rows, or why they did not load. */
|
|
106
|
+
async function settleList<T>(request: Promise<T[]>, fallback: string): Promise<{ rows: T[]; error: string | null }> {
|
|
107
|
+
try {
|
|
108
|
+
return { rows: await request, error: null };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { rows: [], error: error instanceof Error && error.message ? error.message : fallback };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
99
113
|
|
|
100
114
|
/**
|
|
101
115
|
* Members, sent invitations, and invite links for the team on screen. Reloads
|
|
@@ -119,17 +133,22 @@ function useTeamDetails(team: TeamSummary | null) {
|
|
|
119
133
|
const manage = canManageTeam(role);
|
|
120
134
|
const canLink = manage || allowMemberInvites;
|
|
121
135
|
try {
|
|
136
|
+
const none = { rows: [], error: null };
|
|
122
137
|
const [membersResult, invitations, links] = await Promise.all([
|
|
123
138
|
apiClient.getTeamMembers(teamId),
|
|
124
|
-
manage
|
|
125
|
-
|
|
126
|
-
: Promise.resolve([]),
|
|
127
|
-
canLink
|
|
128
|
-
? apiClient.listTeamInviteLinks(teamId).catch(logFailure("cloud.team.pane", "Load team invite links failed", []))
|
|
129
|
-
: Promise.resolve([]),
|
|
139
|
+
manage ? settleList(apiClient.listTeamInvitations(teamId), unavailableText("Pending invitations")) : none,
|
|
140
|
+
canLink ? settleList(apiClient.listTeamInviteLinks(teamId), unavailableText("Invite links")) : none,
|
|
130
141
|
]);
|
|
131
142
|
if (generation.current !== current) return;
|
|
132
|
-
setDetails({
|
|
143
|
+
setDetails({
|
|
144
|
+
members: membersResult.members,
|
|
145
|
+
invitations: invitations.rows,
|
|
146
|
+
links: links.rows,
|
|
147
|
+
invitationsError: invitations.error,
|
|
148
|
+
linksError: links.error,
|
|
149
|
+
loading: false,
|
|
150
|
+
error: null,
|
|
151
|
+
});
|
|
133
152
|
} catch (error) {
|
|
134
153
|
if (generation.current !== current) return;
|
|
135
154
|
setDetails((previous) => ({ ...previous, loading: false, error: errorText(error, "Could not load the team.") }));
|
|
@@ -689,6 +708,8 @@ export function TeamPane({ focused, width, height, close }: PaneProps) {
|
|
|
689
708
|
team={team}
|
|
690
709
|
invitations={details.invitations}
|
|
691
710
|
links={details.links}
|
|
711
|
+
invitationsError={details.invitationsError}
|
|
712
|
+
linksError={details.linksError}
|
|
692
713
|
username={inviteUsername}
|
|
693
714
|
width={contentWidth}
|
|
694
715
|
busy={busy}
|
|
@@ -141,8 +141,9 @@ export async function trialLoadViewSource(
|
|
|
141
141
|
});
|
|
142
142
|
try {
|
|
143
143
|
const loading = loadViewSource(spec, config, controller.signal, registry);
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
loading.catch(() => {
|
|
145
|
+
// Intentional ignore: the race reports load failures; after timeout its result already explains the unchecked source.
|
|
146
|
+
});
|
|
146
147
|
const outcome = await Promise.race([loading, timedOut]);
|
|
147
148
|
if (outcome === "timeout") {
|
|
148
149
|
return [`The source had not answered after ${Math.max(1, Math.round(timeoutMs / 1_000))} s, so it was not checked. The view opens and keeps loading.`];
|
|
@@ -14,6 +14,7 @@ import { PaneStatusBody, TextPromptDialog, usePaneFooter } from "../../../compon
|
|
|
14
14
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
15
15
|
import { useShortcut } from "../../../react/input";
|
|
16
16
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
17
|
+
import { logFailure } from "../../../utils/log-failure";
|
|
17
18
|
import { usePaneInstance, usePaneAppConfig } from "../../../state/app/context";
|
|
18
19
|
import { colors } from "../../../theme/colors";
|
|
19
20
|
import type { PaneProps } from "../../../types/plugin";
|
|
@@ -241,7 +242,7 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
|
|
|
241
242
|
footer="Shift+Enter newline · Esc cancel"
|
|
242
243
|
/>
|
|
243
244
|
),
|
|
244
|
-
}).catch((
|
|
245
|
+
}).catch(logFailure("custom-view.pane", "The view revision prompt failed"));
|
|
245
246
|
const edited = specText ? parseViewSpecOr(specText) : null;
|
|
246
247
|
if (!edited) return;
|
|
247
248
|
if ("error" in edited) throw new Error(edited.error);
|
|
@@ -268,7 +269,7 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
|
|
|
268
269
|
selectedChoiceId="reload"
|
|
269
270
|
/>
|
|
270
271
|
),
|
|
271
|
-
}).catch((
|
|
272
|
+
}).catch(logFailure("custom-view.pane", "The view conflict prompt failed"));
|
|
272
273
|
if (choice === "overwrite") {
|
|
273
274
|
const published = await apiClient.publishTeamViewRevision(current.id, {
|
|
274
275
|
spec: edited.spec as unknown as Record<string, unknown>,
|
|
@@ -295,7 +296,7 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
|
|
|
295
296
|
selectedChoiceId={defaultTeam ?? undefined}
|
|
296
297
|
/>
|
|
297
298
|
),
|
|
298
|
-
}).catch((
|
|
299
|
+
}).catch(logFailure("custom-view.pane", "The view team prompt failed"));
|
|
299
300
|
if (!teamId) return;
|
|
300
301
|
const name = await dialog.prompt<string>({
|
|
301
302
|
content: (context: PromptContext<string>) => (
|
|
@@ -308,7 +309,7 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
|
|
|
308
309
|
width={40}
|
|
309
310
|
/>
|
|
310
311
|
),
|
|
311
|
-
}).catch((
|
|
312
|
+
}).catch(logFailure("custom-view.pane", "The view name prompt failed"));
|
|
312
313
|
if (!name) return;
|
|
313
314
|
const created = await apiClient.createTeamView(teamId, {
|
|
314
315
|
name,
|
|
@@ -14,7 +14,8 @@ const CACHE_POLICY = {
|
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
16
|
export type ImpactFilter = "high" | "medium" | "low" | "all";
|
|
17
|
-
|
|
17
|
+
/** A region the pane cycles through, or one country by its two-letter code (`econ --country TR`). */
|
|
18
|
+
export type CountryFilter = "all" | "US" | "G7" | "EU" | (string & {});
|
|
18
19
|
|
|
19
20
|
export const FILTER_CYCLE: ImpactFilter[] = ["all", "high", "medium", "low"];
|
|
20
21
|
/**
|
|
@@ -187,7 +188,7 @@ export function matchesCountry(event: EconEvent, filter: CountryFilter): boolean
|
|
|
187
188
|
if (filter === "US") return event.country === "US";
|
|
188
189
|
if (filter === "G7") return G7_COUNTRIES.has(event.country);
|
|
189
190
|
if (filter === "EU") return event.country === "EU";
|
|
190
|
-
return
|
|
191
|
+
return event.country === filter;
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
export function formatCountdown(ms: number): string {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fredSeriesUrl } from "../../../sources/gloomberb-cloud/fred-series";
|
|
1
|
+
import { fredSeriesUrl, seriesNotOnFred } from "../../../sources/gloomberb-cloud/fred-series";
|
|
2
2
|
import type { SeriesDefinition, SeriesFrequency } from "../../../series/types";
|
|
3
3
|
|
|
4
4
|
type FredRow = readonly [seriesId: string, name: string, unit: string, frequency: SeriesFrequency, tags?: readonly string[]];
|
|
@@ -8,6 +8,8 @@ type FredRow = readonly [seriesId: string, name: string, unit: string, frequency
|
|
|
8
8
|
* maturities (UST:2Y and so on, from the yield curve) and the credit spreads
|
|
9
9
|
* and yields (from the credit plugin). Ids follow the server's allowlist: a
|
|
10
10
|
* series added there is listed here to reach the catalog.
|
|
11
|
+
* Türkiye's CPI is served the same way but is not on FRED; its rows name
|
|
12
|
+
* TurkStat (`seriesNotOnFred`).
|
|
11
13
|
*/
|
|
12
14
|
const GROUPS: ReadonlyArray<{ group: string; rows: readonly FredRow[] }> = [
|
|
13
15
|
{
|
|
@@ -123,6 +125,8 @@ const GROUPS: ReadonlyArray<{ group: string; rows: readonly FredRow[] }> = [
|
|
|
123
125
|
["T5YIE", "5Y breakeven inflation", "%", "daily", ["breakeven", "expectations", "TIPS"]],
|
|
124
126
|
["T10YIE", "10Y breakeven inflation", "%", "daily", ["breakeven", "expectations", "TIPS"]],
|
|
125
127
|
["T5YIFR", "5Y, 5Y forward inflation expectation", "%", "daily", ["breakeven", "expectations", "5y5y"]],
|
|
128
|
+
["TURCPIYOY", "Türkiye CPI, y/y", "%", "monthly", ["CPI", "headline", "Turkey", "TR", "inflation", "lira"]],
|
|
129
|
+
["TURCPIMOM", "Türkiye CPI, m/m", "%", "monthly", ["CPI", "headline", "Turkey", "TR", "inflation", "lira"]],
|
|
126
130
|
],
|
|
127
131
|
},
|
|
128
132
|
{
|
|
@@ -191,7 +195,7 @@ export const FRED_CATALOG_SERIES: readonly SeriesDefinition[] = GROUPS.flatMap((
|
|
|
191
195
|
frequency,
|
|
192
196
|
group,
|
|
193
197
|
...(tags?.length ? { tags } : {}),
|
|
194
|
-
publisher: "FRED",
|
|
198
|
+
publisher: seriesNotOnFred(seriesId)?.publisher ?? "FRED",
|
|
195
199
|
url: fredSeriesUrl(seriesId),
|
|
196
200
|
expression: `FRED:${seriesId}`,
|
|
197
201
|
}))
|
|
@@ -51,12 +51,23 @@ const SERIES_MAP: Record<string, FredMapping> = {
|
|
|
51
51
|
"unit labor costs q/q": { seriesId: "ULCNFB", displayMode: "qoq-annualized", relatedTickers: ["SPY", "^TNX"] },
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
/** Other countries' releases Gloom Cloud serves a series for, by country and normalized title. */
|
|
55
|
+
const COUNTRY_SERIES_MAP: Record<string, Record<string, FredMapping>> = {
|
|
56
|
+
// TurkStat's CPI, published as percent changes; the related ticker is the US-listed MSCI Turkey ETF.
|
|
57
|
+
TR: {
|
|
58
|
+
"cpi y/y": { seriesId: "TURCPIYOY", displayMode: "level", relatedTickers: ["TUR"] },
|
|
59
|
+
"cpi m/m": { seriesId: "TURCPIMOM", displayMode: "level", relatedTickers: ["TUR"] },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
54
63
|
function normalizeEventTitle(title: string): string {
|
|
55
64
|
return title.toLowerCase().trim();
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export function resolveFredMapping(eventTitle: string, country: string): FredMapping | null {
|
|
59
|
-
|
|
68
|
+
const elsewhere = COUNTRY_SERIES_MAP[country];
|
|
69
|
+
if (elsewhere) return elsewhere[normalizeEventTitle(eventTitle)] ?? null;
|
|
70
|
+
// Otherwise only US events have FRED data
|
|
60
71
|
if (country !== "US" && country !== "USD") return null;
|
|
61
72
|
|
|
62
73
|
const normalized = normalizeEventTitle(eventTitle);
|
|
@@ -13,8 +13,20 @@ import { secListingKey } from "../../../utils/sec";
|
|
|
13
13
|
* reads. A proxy is filed once a year and the extraction does not change
|
|
14
14
|
* after it is verified, so a statement is kept for a long time; the list
|
|
15
15
|
* of years is refreshed more often, since a new filing adds one.
|
|
16
|
+
*
|
|
17
|
+
* An account without Pro is served the preview (`access: "preview"`): each
|
|
18
|
+
* executive's total pay from the latest proxy. A kept answer is reused only
|
|
19
|
+
* for the plan it was served to, so a new Pro account never sees its old
|
|
20
|
+
* preview and an account that lost Pro never sees a kept breakdown.
|
|
16
21
|
*/
|
|
17
22
|
|
|
23
|
+
/** Pro's full statements, or the free preview. */
|
|
24
|
+
export type ProxyTier = "full" | "preview";
|
|
25
|
+
|
|
26
|
+
function tierOf(value: unknown): ProxyTier {
|
|
27
|
+
return (value as { access?: unknown } | null)?.access === "preview" ? "preview" : "full";
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
const LIST_KIND = "proxies";
|
|
19
31
|
const STATEMENT_KIND = "proxy";
|
|
20
32
|
const CACHE_SOURCE = "executives";
|
|
@@ -57,6 +69,7 @@ export function resetExecutivesPersistence(): void {
|
|
|
57
69
|
function loadCached<T>(
|
|
58
70
|
kind: string,
|
|
59
71
|
key: string,
|
|
72
|
+
tier: ProxyTier,
|
|
60
73
|
active: Map<string, ActiveRequest<T>>,
|
|
61
74
|
policy: { staleMs: number; expireMs: number },
|
|
62
75
|
force: boolean,
|
|
@@ -65,14 +78,15 @@ function loadCached<T>(
|
|
|
65
78
|
const store = persistence;
|
|
66
79
|
const options = { sourceKey: CACHE_SOURCE, schemaVersion: CACHE_SCHEMA_VERSION };
|
|
67
80
|
const failedKey = `${kind}:${key}`;
|
|
81
|
+
const activeKey = `${key}|${tier}`;
|
|
68
82
|
const hit = store?.getResource<T>(kind, key, options);
|
|
69
|
-
if (!force && !failedRefreshes.has(failedKey) && hit && !hit.stale) {
|
|
83
|
+
if (!force && !failedRefreshes.has(failedKey) && hit && !hit.stale && tierOf(hit.value) === tier) {
|
|
70
84
|
return Promise.resolve({ data: hit.value, fetchedAt: hit.fetchedAt });
|
|
71
85
|
}
|
|
72
|
-
const existing = active.get(
|
|
86
|
+
const existing = active.get(activeKey);
|
|
73
87
|
if (!force && existing?.store === store) return existing.promise;
|
|
74
88
|
const request: ActiveRequest<T> = { store, promise: null! };
|
|
75
|
-
const current = () => persistence === store && active.get(
|
|
89
|
+
const current = () => persistence === store && active.get(activeKey) === request;
|
|
76
90
|
request.promise = Promise.resolve().then(fetch).then((data) => {
|
|
77
91
|
const fetchedAt = Date.now();
|
|
78
92
|
if (current()) {
|
|
@@ -92,14 +106,14 @@ function loadCached<T>(
|
|
|
92
106
|
throw error;
|
|
93
107
|
}
|
|
94
108
|
const fallback = store?.getResource<T>(kind, key, { ...options, allowExpired: true });
|
|
95
|
-
if (!fallback) throw error;
|
|
109
|
+
if (!fallback || tierOf(fallback.value) !== tier) throw error;
|
|
96
110
|
return {
|
|
97
111
|
data: fallback.value,
|
|
98
112
|
fetchedAt: fallback.fetchedAt,
|
|
99
113
|
refreshError: (error instanceof Error ? error.message : String(error)).trim() || "Request failed",
|
|
100
114
|
};
|
|
101
|
-
}).finally(() => { if (active.get(
|
|
102
|
-
active.set(
|
|
115
|
+
}).finally(() => { if (active.get(activeKey) === request) active.delete(activeKey); });
|
|
116
|
+
active.set(activeKey, request);
|
|
103
117
|
return request.promise;
|
|
104
118
|
}
|
|
105
119
|
|
|
@@ -107,6 +121,8 @@ interface ProxyLoadOptions {
|
|
|
107
121
|
force?: boolean;
|
|
108
122
|
/** The listing's venue and company, for a listing outside the US. */
|
|
109
123
|
listing?: IssuerListingParams;
|
|
124
|
+
/** What the account's plan is served: `full` unless it has no Pro. */
|
|
125
|
+
tier?: ProxyTier;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
export async function loadProxyStatements(
|
|
@@ -116,7 +132,7 @@ export async function loadProxyStatements(
|
|
|
116
132
|
const symbol = ticker.toUpperCase();
|
|
117
133
|
// SAN in Paris is not SAN in New York: a listing abroad is cached under its venue.
|
|
118
134
|
const key = secListingKey(symbol, options?.listing?.exchange);
|
|
119
|
-
return loadCached(LIST_KIND, key, activeListFetches, LIST_CACHE_POLICY, options?.force ?? false,
|
|
135
|
+
return loadCached(LIST_KIND, key, options?.tier ?? "full", activeListFetches, LIST_CACHE_POLICY, options?.force ?? false,
|
|
120
136
|
() => apiClient.getProxyStatements(symbol, options?.listing));
|
|
121
137
|
}
|
|
122
138
|
|
|
@@ -127,6 +143,6 @@ export async function loadProxyStatement(
|
|
|
127
143
|
): Promise<ProxyResult<CloudProxyStatementPayload>> {
|
|
128
144
|
const symbol = ticker.toUpperCase();
|
|
129
145
|
const key = `${secListingKey(symbol, options?.listing?.exchange)}:${year}`;
|
|
130
|
-
return loadCached(STATEMENT_KIND, key, activeStatementFetches, STATEMENT_CACHE_POLICY, options?.force ?? false,
|
|
146
|
+
return loadCached(STATEMENT_KIND, key, options?.tier ?? "full", activeStatementFetches, STATEMENT_CACHE_POLICY, options?.force ?? false,
|
|
131
147
|
() => apiClient.getProxyStatement(symbol, year, options?.listing));
|
|
132
148
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { PluginModule } from "../plugin-module";
|
|
2
|
-
import { proLockNote } from "../shared/pro-lock-note";
|
|
3
2
|
import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
|
|
4
3
|
import {
|
|
5
4
|
attachExecutivesPersistence,
|
|
@@ -11,21 +10,6 @@ import { isKnownNonUsListing } from "../../../utils/sec";
|
|
|
11
10
|
const description =
|
|
12
11
|
"Named executive officers and what they were paid, read from the company's proxy statement and checked against the filing.";
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
* EXEC is Pro whole (`useProFeatureWall`). The proxy statements it reads are
|
|
16
|
-
* in the SEC filing list, which needs no account: `filings` lists them with a
|
|
17
|
-
* link to each, where the Summary Compensation Table is the pay as filed,
|
|
18
|
-
* without EXEC's parsed figures.
|
|
19
|
-
*/
|
|
20
|
-
function executivesLockNote(lock: { gate: "sign-in" | "pro"; symbol: string | null }): string {
|
|
21
|
-
return proLockNote(lock, {
|
|
22
|
-
token: "EXEC",
|
|
23
|
-
does: "it reads each proxy statement into the named executive officers and what they were paid",
|
|
24
|
-
shows: "it",
|
|
25
|
-
free: (ticker, company) => `gloomberb filings ${ticker} --form DEF14A lists ${company}'s proxy statements, each with its filing date and a link to the filing, whose Summary Compensation Table holds the pay as filed.`,
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
13
|
export const executivesModule: PluginModule = {
|
|
30
14
|
setup(ctx) {
|
|
31
15
|
attachExecutivesPersistence(ctx.persistence);
|
|
@@ -47,8 +31,8 @@ export const executivesModule: PluginModule = {
|
|
|
47
31
|
{
|
|
48
32
|
id: EXECUTIVES_PANE_ID,
|
|
49
33
|
reportFreshness: { source: "SEC EDGAR", status: "not-a-feed", basis: "proxy statements" },
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
// Open on every plan: without Pro each executive's total pay from the
|
|
35
|
+
// latest proxy, with the breakdown and earlier years locked (pane.tsx).
|
|
52
36
|
name: "Executives",
|
|
53
37
|
icon: "X",
|
|
54
38
|
component: ExecutivesPane,
|