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
@@ -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
- /** Signed USD value at the current mark; only these marks weight the basket. */
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
- const rates = market.fxRates ?? {};
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 error =
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 === portfolio.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 value = marked != null && Number.isFinite(marked) ? marked : null;
510
- const returns = history?.returns ?? [];
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
- historyAsOf: history?.asOf ?? null,
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
- const benchmark = proxy(RISK_BENCHMARK);
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: benchmark },
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"), benchmark),
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"), benchmark),
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: benchmark,
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)} ${row.currency}${row.markSource === "close" || row.markSource === "regular-close" ? ` at ${row.priceAsOf} close` : ""}${row.convertedFrom ? ` from ${row.convertedFrom} at daily FX` : ""}; history ${row.historyAsOf}`,
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
- rows.map(riskAmountBase).find(Boolean) ?? model.portfolio.currency,
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 = { members: [], invitations: [], links: [], loading: false, error: null };
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
- ? apiClient.listTeamInvitations(teamId).catch(logFailure("cloud.team.pane", "Load team invitations failed", []))
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({ members: membersResult.members, invitations, links, loading: false, error: null });
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
- // A load that outlives the wait has nobody left to tell.
145
- loading.catch(() => {});
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(() => undefined);
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(() => undefined);
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(() => undefined);
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(() => undefined);
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,
@@ -270,6 +270,7 @@ export function parseViewSpec(value: unknown): ViewSpec | null {
270
270
  try {
271
271
  decoded = JSON.parse(value);
272
272
  } catch {
273
+ // Intentional ignore: malformed input is represented by a null spec.
273
274
  return null;
274
275
  }
275
276
  }
@@ -14,7 +14,8 @@ const CACHE_POLICY = {
14
14
  } as const;
15
15
 
16
16
  export type ImpactFilter = "high" | "medium" | "low" | "all";
17
- export type CountryFilter = "all" | "US" | "G7" | "EU";
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 true;
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
- // Only US events have FRED data
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(key);
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(key) === request;
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(key) === request) active.delete(key); });
102
- active.set(key, request);
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
- reportLockNote: executivesLockNote,
51
- reportRequirements: ["Gloom Cloud Pro"],
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,