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
@@ -12,7 +12,7 @@ import {
12
12
  import { formatCompact, formatPercentRaw } from "../../../../utils/format";
13
13
  import { currencyPriceMinDigits, formatMarketCostWithCurrency, formatMarketPrice, formatMarketPriceWithCurrency, formatMarketQuantity, quoteFormatOptions } from "../../../../market-data/market/format";
14
14
  import { resolvePriceBasis } from "../../../../market-data/market/price-basis";
15
- import { getPortfolioPositionMetrics, resolvePortfolioMarketValue, resolvePortfolioPositionPnl } from "../../../../portfolio/position-metrics";
15
+ import { getPortfolioPositionMetrics, lotCostAtTodaysRate, lotCostFxRate, resolvePortfolioMarketValue, resolvePortfolioPositionPnl } from "../../../../portfolio/position-metrics";
16
16
  import {
17
17
  getPortfolioValuationDisplay,
18
18
  getPortfolioValuationTime,
@@ -282,6 +282,12 @@ interface PortfolioPositionValue {
282
282
  row: Record<string, unknown>;
283
283
  /** A future's contract size and settlement from its terms; null for any other holding. */
284
284
  futures: PortfolioFuturesTerms | null;
285
+ /**
286
+ * Only on a lot held in another currency than the portfolio's: the rate its
287
+ * cost converted at (one unit of its currency in the portfolio's, when
288
+ * bought), or null when it converted at today's rate.
289
+ */
290
+ costFx?: { rate: number; currency: string } | null;
285
291
  }
286
292
 
287
293
  /** What a row's value is priced at: exported after every other field, so existing columns keep their places. */
@@ -309,6 +315,8 @@ export interface PortfolioValuation {
309
315
  unknownContractSize: Set<string>;
310
316
  /** Futures valued at the close without a confirmed settlement to value them at. */
311
317
  unsettled: Set<string>;
318
+ /** Holdings in another currency whose cost converts at today's rate: no purchase date or cost FX rate was entered. */
319
+ costAtTodaysRate: Set<string>;
312
320
  /** Long and short market value of the priced lots, and their net and gross, in the portfolio's currency. */
313
321
  exposure: PortfolioExposure;
314
322
  }
@@ -333,6 +341,15 @@ function portfolioExposure(values: Iterable<number | null | undefined>): Portfol
333
341
  return { long, short, net: long + short, gross: long - short };
334
342
  }
335
343
 
344
+ /**
345
+ * What a book says of holdings in another currency whose cost converts at
346
+ * today's rate, which leaves the currency's move since purchase out of their
347
+ * P&L; `named` lists them as the table names its rows.
348
+ */
349
+ export function costAtTodaysRateNote(named: string): string {
350
+ return `Cost converted at today's rate for ${named}; give portfolio position set a --date or --cost-fx to use the rate when bought.`;
351
+ }
352
+
336
353
  /**
337
354
  * Values every position of a portfolio in the portfolio's currency, as
338
355
  * `portfolio show` prints it: cost basis, market value and unrealized P&L
@@ -368,6 +385,7 @@ export async function valuePortfolioPositions({
368
385
  const brokerPnlSymbols = new Set<string>();
369
386
  const unknownContractSize = new Set<string>();
370
387
  const unsettled = new Set<string>();
388
+ const costAtTodaysRate = new Set<string>();
371
389
  const values: PortfolioPositionValue[] = [];
372
390
  const holdings: AllocationHolding[] = [];
373
391
  const known = (value: number | null | undefined): number | null => value != null && Number.isFinite(value) ? value : null;
@@ -409,11 +427,17 @@ export async function valuePortfolioPositions({
409
427
  const valuationQuote = getPortfolioValuationDisplay(metrics, quote, basis, settlement);
410
428
  const atSettlement = valuesAtSettlement(metrics, basis);
411
429
  const positionCurrency = metrics.positionCurrency;
412
- const costBasisBase = positionCurrency ? await toBase(metrics.totalCost, positionCurrency) : Number.NaN;
413
-
414
430
  const positionRate = positionCurrency ? await toBase(1, positionCurrency) : Number.NaN;
431
+ // The cost converts at the rate the lot was bought at when one was entered, else at today's, as its value does.
415
432
  const baseMetrics = getPortfolioPositionMetrics({ ...ticker, metadata: { ...ticker.metadata, positions: [position] } }, id, quoteCurrency,
416
433
  { currency, convert: value => value * positionRate }, quote);
434
+ const boughtRate = positionCurrency ? lotCostFxRate(position, positionCurrency, currency) : null;
435
+ const costBasisBase = !positionCurrency ? Number.NaN
436
+ : boughtRate !== null ? baseMetrics.totalCost : await toBase(metrics.totalCost, positionCurrency);
437
+ // Only a lot entered by hand can be given the rate it was bought at; a broker's lot reads as it always has.
438
+ const atTodaysRate = position.broker === "manual" && !!positionCurrency && lotCostAtTodaysRate(position, positionCurrency, currency);
439
+ if (atTodaysRate && metrics.hasCostBasis) costAtTodaysRate.add(ticker.metadata.ticker);
440
+ const costFx = boughtRate !== null ? { rate: boughtRate, currency } : atTodaysRate ? null : undefined;
417
441
  const currentValueBase = resolvePortfolioMarketValue(baseMetrics,
418
442
  valuationQuote ? await toBase(valuationQuote.price, quoteCurrency) : null)?.gross ?? null;
419
443
  const selectedPnl = resolvePortfolioPositionPnl(baseMetrics,
@@ -436,7 +460,7 @@ export async function valuePortfolioPositions({
436
460
  ? { change: await toBase(metrics.totalPriceUnits * change, quoteCurrency),
437
461
  previousValue: await toBase(metrics.grossPriceUnits * (valuationQuote.price - change), quoteCurrency) }
438
462
  : null;
439
- values.push({ ticker, quote, activeQuote, headline, extended, position, metrics, pnl, futures: futuresTerms,
463
+ values.push({ ticker, quote, activeQuote, headline, extended, position, metrics, pnl, futures: futuresTerms, ...(costFx !== undefined ? { costFx } : {}),
440
464
  dayMove: dayMove && Number.isFinite(dayMove.change) && Number.isFinite(dayMove.previousValue) ? dayMove : null,
441
465
  valuation: { valuationBasis: basis, valuationPrice: known(valuationQuote?.price),
442
466
  valuationAsOf: valuationTimeIso(getPortfolioValuationTime(quote, basis, metrics, settlement)),
@@ -455,7 +479,7 @@ export async function valuePortfolioPositions({
455
479
 
456
480
  const exposure = portfolioExposure(values.map((entry) => entry.position ? entry.row.marketValue as number | null : null));
457
481
  return { basis, positions: values, holdings, totalPnl, unavailablePnl, unavailableCost, unavailableMarketValue, brokerPnlSymbols,
458
- unknownContractSize, unsettled, exposure };
482
+ unknownContractSize, unsettled, costAtTodaysRate, exposure };
459
483
  }
460
484
 
461
485
  /**
@@ -725,6 +749,7 @@ async function showCollectionWithMarketData(
725
749
  brokerPnlSymbols,
726
750
  unknownContractSize,
727
751
  unsettled,
752
+ costAtTodaysRate,
728
753
  exposure,
729
754
  } = valuation;
730
755
  const { allocation, cash } = await valuePortfolioAllocation({ valuation, portfolio: matchedPortfolio, account, toBase });
@@ -782,6 +807,8 @@ async function showCollectionWithMarketData(
782
807
  contractMultiplier: entry.position.multiplier ?? null,
783
808
  contractSize: entry.futures.size?.size ?? null,
784
809
  } : {}),
810
+ // Only on a lot held in another currency: the rate its cost converted at when bought, null when at today's.
811
+ ...(entry.costFx !== undefined ? { costFxRate: entry.costFx?.rate ?? null, costFxCurrency: entry.costFx?.currency ?? null } : {}),
785
812
  };
786
813
  });
787
814
  const totalPnlValue = unavailablePnl.size > 0 ? null : totalPnl;
@@ -884,6 +911,8 @@ async function showCollectionWithMarketData(
884
911
  exposure,
885
912
  ...(unknownContractSize.size > 0 ? { unknownContractSizeSymbols: [...unknownContractSize] } : {}),
886
913
  ...(unsettled.size > 0 ? { unsettledSymbols: [...unsettled] } : {}),
914
+ // Holdings in another currency with no purchase date or cost FX rate: their cost converts at today's rate.
915
+ ...(costAtTodaysRate.size > 0 ? { costAtTodaysRateSymbols: [...costAtTodaysRate] } : {}),
887
916
  accountingBasis, ...(manualAccounting ? { manualAccounting } : {}),
888
917
  ...(matchedPortfolio.importedFrom ? { importedFrom: matchedPortfolio.importedFrom } : {}),
889
918
  ...(explain ? { glossary: glossaryExport(explained) } : {}),
@@ -1038,6 +1067,7 @@ async function showCollectionWithMarketData(
1038
1067
  const targetNote = describeTargetSum(allocation.targetSum, allocation.weightBasis);
1039
1068
  if (targetNote) notes.push(cliStyles.muted(targetNote));
1040
1069
  if (brokerPnlSymbols.size > 0) notes.push(cliStyles.muted(`P&L for ${named(brokerPnlSymbols)} is the broker's last snapshot.`));
1070
+ if (costAtTodaysRate.size > 0) notes.push(cliStyles.muted(costAtTodaysRateNote(named(costAtTodaysRate))));
1041
1071
  if (unknownContractSize.size > 0) {
1042
1072
  notes.push(cliStyles.warning(`No contract size for ${named(unknownContractSize)}, so ${unknownContractSize.size === 1 ? "its" : "their"} value, cost and P&L are not shown.`));
1043
1073
  }
@@ -125,7 +125,7 @@ async function addToWatchlistCommand(args: string[], file: string | undefined, e
125
125
  dryRun,
126
126
  add: (ticker) => addTickerToWatchlist(ticker, watchlist.id),
127
127
  });
128
- printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated, strays: list.strays, retry, dryRun });
128
+ printSymbolRows(ctx, rows, { verb: "add", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated, notes: list.notes, retry, dryRun });
129
129
  });
130
130
  }
131
131
 
@@ -158,7 +158,7 @@ async function removeFromWatchlistCommand(args: string[], file: string | undefin
158
158
  store: context.store,
159
159
  remove: (ticker) => removeWatchlistMembership(ticker, watchlist.id),
160
160
  });
161
- printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated, strays: list.strays });
161
+ printSymbolRows(ctx, rows, { verb: "remove", collection: { kind: "watchlist", name: watchlist.name }, repeated: list.repeated, notes: list.notes });
162
162
  });
163
163
  }
164
164
 
@@ -24,6 +24,7 @@ import {
24
24
  valuationTimeText,
25
25
  valuePortfolioAllocation,
26
26
  valuePortfolioPositions,
27
+ costAtTodaysRateNote,
27
28
  type CollectionMatch,
28
29
  type PortfolioValuation,
29
30
  } from "./cli/render";
@@ -401,6 +402,7 @@ async function portfolioHoldings(
401
402
  : []),
402
403
  ...(targetNote ? [targetNote] : []),
403
404
  ...(target.portfolio.brokerInstanceId && !resolved.account ? [BROKER_ACCOUNT_MISSING_NOTE] : []),
405
+ ...(valuation.costAtTodaysRate.size > 0 ? [costAtTodaysRateNote([...valuation.costAtTodaysRate].join(", "))] : []),
404
406
  ];
405
407
 
406
408
  const positionColumns = allocation.weightBasis === "gross"
@@ -53,6 +53,7 @@ import {
53
53
  renderSummarySegments,
54
54
  } from "../summary";
55
55
  import { usePortfolioAccountState } from "../summary/live-accounts";
56
+ import { useOfflineBrokerFooter } from "../../shared/broker-reconnect";
56
57
  import {
57
58
  getCollectionEntries,
58
59
  getPortfolioPaneSettings,
@@ -190,8 +191,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
190
191
  const flashSymbols = useQuoteFlashMap(financialsMap, valueFlashingEnabled);
191
192
 
192
193
  const accountStateInput = useMemo(() => ({ brokerAccounts, config }), [brokerAccounts, config]);
193
- const { accountState, accountsError, brokerError } = usePortfolioAccountState(currentPortfolio, accountStateInput);
194
+ const { accountState, accountsError, offline } = usePortfolioAccountState(currentPortfolio, accountStateInput);
194
195
  const brokerPortfolio = isPortfolioTab && !!currentPortfolio?.brokerInstanceId;
196
+ // The positions stay as last synced while the broker is away, dated, with one key to bring it back.
197
+ const offlineBrokerFooter = useOfflineBrokerFooter(brokerPortfolio ? offline : null);
195
198
  const portfolioTargets = currentPortfolio?.targetWeights;
196
199
  const columns = useMemo(
197
200
  () => resolveVisibleColumns(paneSettings.columnIds, isPortfolioTab, { targetWeights: hasTargetWeights(currentPortfolio) }),
@@ -548,7 +551,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
548
551
  accountState: summaryAccountState,
549
552
  accountStatusText: accountsError
550
553
  ? `Accounts unavailable: ${accountsError}`
551
- : brokerPortfolio ? brokerError ?? (accountState ? undefined : "Acct missing") : undefined,
554
+ : brokerPortfolio && !accountState ? "Acct missing" : undefined,
555
+ brokerOffline: brokerPortfolio && offline ? { dated: offline.syncedAt != null } : undefined,
552
556
  financialsMap,
553
557
  isPortfolioTab,
554
558
  refreshingSize,
@@ -557,9 +561,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
557
561
  }), [financialsMap, sortedTickers], [
558
562
  accountState,
559
563
  accountsError,
560
- brokerError,
561
564
  brokerPortfolio,
562
565
  isPortfolioTab,
566
+ offline,
563
567
  portfolioSummaryTotals,
564
568
  refreshingSize,
565
569
  summaryAccountState,
@@ -637,11 +641,14 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
637
641
 
638
642
  usePaneFooter("portfolio-list", () => ({
639
643
  info: [
644
+ ...offlineBrokerFooter.asOf,
640
645
  ...summaryFooterInfo,
646
+ ...offlineBrokerFooter.status,
641
647
  ...(fxStatusText && !fxWarning ? [{ id: "fx", parts: [{ text: `FX ${fxStatusText}`, tone: "muted" as const }] }] : []),
642
648
  ...(showsPerpetuals && perpetuals.stale ? [{ id: "perpetuals", parts: [{ text: "perpetuals stale", tone: "warning" as const }] }] : []),
643
649
  ],
644
650
  hints: [
651
+ ...offlineBrokerFooter.hints,
645
652
  ...(removalCollection
646
653
  ? [{
647
654
  id: "delete",
@@ -676,6 +683,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
676
683
  fxStatusText,
677
684
  fxWarning,
678
685
  isPortfolioTab,
686
+ offlineBrokerFooter,
679
687
  perpetuals.stale,
680
688
  removalCollection,
681
689
  removeSelectedTicker,
@@ -406,6 +406,7 @@ export function layoutPortfolioSummaryHeader(
406
406
  export function buildPortfolioFooterSegments({
407
407
  accountState,
408
408
  accountStatusText,
409
+ brokerOffline,
409
410
  financialsMap,
410
411
  isPortfolioTab,
411
412
  refreshingSize,
@@ -414,17 +415,25 @@ export function buildPortfolioFooterSegments({
414
415
  }: {
415
416
  accountState: PortfolioSummaryAccountState | null;
416
417
  accountStatusText?: string;
418
+ /**
419
+ * The portfolio's broker is offline. The pane says why, which stands in for
420
+ * the missing account and the quote time, and when it knows, when the
421
+ * positions were synced, which also dates the account.
422
+ */
423
+ brokerOffline?: { dated: boolean };
417
424
  financialsMap: Map<string, TickerFinancials>;
418
425
  isPortfolioTab: boolean;
419
426
  refreshingSize: number;
420
427
  sortedTickers: TickerRecord[];
421
428
  totals: PortfolioSummaryTotals;
422
429
  }): PaneFooterSegment[] {
430
+ const source: PaneFooterSegment[] = accountState ? [{ id: "source", parts: [{ text: accountState.sourceLabel, tone: "muted" }] }] : [];
431
+ if (isPortfolioTab && brokerOffline) return brokerOffline.dated ? [] : source;
423
432
  const accountStatus: PaneFooterSegment[] = isPortfolioTab && accountStatusText
424
433
  ? [{ id: "account-status", parts: [{ text: accountStatusText, tone: "muted" }] }]
425
434
  : [];
426
435
  // Cached account numbers stay on screen when a live refresh fails, so the failure sits beside their date.
427
- if (accountState) return [{ id: "source", parts: [{ text: accountState.sourceLabel, tone: "muted" }] }, ...accountStatus];
436
+ if (accountState) return [...source, ...accountStatus];
428
437
  if (isPortfolioTab ? !totals.hasPositions && !accountStatusText : totals.watchlistCount === 0) return [];
429
438
 
430
439
  const lastRefreshTimestamp = getMostRecentQuoteUpdate(
@@ -6,6 +6,9 @@ import type { Portfolio } from "../../../../types/ticker";
6
6
  import type { BrokerAccount } from "../../../../types/trading";
7
7
  import { getBrokerInstance } from "../../../../utils/broker-instances";
8
8
  import { usePluginBrokerActions } from "../../../runtime";
9
+ import type { OfflineBroker } from "../../shared/broker-reconnect";
10
+ import { tf } from "../../../../i18n";
11
+ import { isSignedInBrokerProfile, signedInBrokerId } from "../../../../brokers/signed-in/profile";
9
12
  import { resolvePortfolioAccountState, type ResolvedPortfolioAccountState } from "./index";
10
13
 
11
14
  const EMPTY_ACCOUNTS = { instanceId: null, accounts: [] as BrokerAccount[], error: null };
@@ -85,11 +88,40 @@ export interface PortfolioAccountStateResult {
85
88
  /** Set when the broker refused to list accounts, so "no cash" is not mistaken for a clean empty. */
86
89
  accountsError: string | null;
87
90
  /**
88
- * The broker's last call failed, such as the sync at startup after its
89
- * sign-in lapsed. Accounts are listed only while it is connected, so this
90
- * is the only word of why the account is missing or stale.
91
+ * The portfolio's broker while it cannot be reached, such as after its
92
+ * sign-in lapsed: the positions on screen are its last sync. Accounts are
93
+ * listed only while it is connected, so this is the only word of why the
94
+ * account is missing or stale.
91
95
  */
92
- brokerError: string | null;
96
+ offline: OfflineBroker | null;
97
+ }
98
+
99
+ /**
100
+ * The broker behind a portfolio while Gloom cannot reach it: disconnected,
101
+ * failing, or without the plugin that connects it. Null while it is connected
102
+ * or connecting, before it reports a status, for a profile turned off, and for
103
+ * one that only syncs on demand (IBKR Flex), which is never connected.
104
+ */
105
+ export function resolveOfflineBroker(
106
+ portfolio: Portfolio | null,
107
+ brokerInstance: BrokerInstanceConfig | null | undefined,
108
+ broker: BrokerAdapter | null | undefined,
109
+ status: BrokerConnectionStatus | null,
110
+ ): OfflineBroker | null {
111
+ if (!portfolio || !brokerInstance || brokerInstance.enabled === false) return null;
112
+ const syncedAt = portfolio.lastSyncedAt ?? brokerInstance.lastSyncedAt ?? null;
113
+ if (!broker) {
114
+ // A profile signed in through Gloom names its broker, not the adapter it needs.
115
+ const brokerName = (isSignedInBrokerProfile(brokerInstance) ? signedInBrokerId(brokerInstance) : brokerInstance.brokerType).toUpperCase();
116
+ return { instance: brokerInstance, brokerName, syncedAt, reason: tf("{broker} unavailable", { broker: brokerName }), reconnectable: false };
117
+ }
118
+ if (!status || status.state === "connected" || status.state === "connecting") return null;
119
+ if (status.mode?.toLowerCase() === "flex") return null;
120
+ const brokerName = broker.describeInstance?.(brokerInstance).brokerName ?? broker.name;
121
+ const reason = status.state === "error"
122
+ ? status.message?.trim() || tf("{broker} unavailable", { broker: brokerName })
123
+ : tf("{broker} disconnected", { broker: brokerName });
124
+ return { instance: brokerInstance, brokerName, syncedAt, reason, reconnectable: true };
93
125
  }
94
126
 
95
127
  /**
@@ -117,9 +149,12 @@ export function usePortfolioAccountState(
117
149
  () => resolvePortfolioAccountState(portfolio, state, snapshot),
118
150
  [portfolio, snapshot, state.brokerAccounts, state.config],
119
151
  );
120
- const brokerError = live.status?.state === "error" ? live.status.message?.trim() || "Broker unavailable" : null;
152
+ const offline = useMemo(
153
+ () => resolveOfflineBroker(portfolio, brokerInstance, broker, live.status),
154
+ [broker, brokerInstance, live.status, portfolio],
155
+ );
121
156
  return useMemo(
122
- () => ({ accountState, accountsError: live.error, brokerError }),
123
- [accountState, brokerError, live.error],
157
+ () => ({ accountState, accountsError: live.error, offline }),
158
+ [accountState, live.error, offline],
124
159
  );
125
160
  }
@@ -0,0 +1,105 @@
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { signedInBrokerForProfile } from "../../../brokers/signed-in/connect";
3
+ import { isSignedInBrokerProfile } from "../../../brokers/signed-in/profile";
4
+ import { requestBrokerSignIn } from "../../../brokers/signed-in/sign-in-dialog";
5
+ import type { PaneFooterSegment, PaneHint } from "../../../components/layout/pane/footer/model";
6
+ import { t, tf } from "../../../i18n";
7
+ import type { BrokerInstanceConfig } from "../../../types/config";
8
+ import { formatShortDate } from "../../../utils/datetime-format";
9
+ import { usePluginAppActions, usePluginBrokerActions } from "../../runtime";
10
+
11
+ /** A broker profile Gloom cannot reach right now, so what a pane shows of it is from its last sync. */
12
+ export interface OfflineBroker {
13
+ instance: BrokerInstanceConfig;
14
+ brokerName: string;
15
+ /** When the profile last synced its positions, or null when it never has. */
16
+ syncedAt: number | null;
17
+ /** Why it is offline, in the broker's words when it gave some. */
18
+ reason: string;
19
+ /** False when no plugin connects it, so pressing a key could not help. */
20
+ reconnectable: boolean;
21
+ }
22
+
23
+ /** The key onboarding gives "Connect a broker". */
24
+ const RECONNECT_BROKER_KEY = "b";
25
+
26
+ /** `as of 14:02` today, `as of Oct 17 14:02` before. */
27
+ function formatBrokerSyncedAt(syncedAt: number, now = Date.now()): string {
28
+ const date = new Date(syncedAt);
29
+ const time = date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
30
+ const today = date.toDateString() === new Date(now).toDateString();
31
+ return today
32
+ ? tf("as of {time}", { time })
33
+ : tf("as of {date} {time}", { date: formatShortDate(date, { year: false }), time });
34
+ }
35
+
36
+ export interface OfflineBrokerFooter {
37
+ /** `as of …`, when the profile has synced. */
38
+ asOf: PaneFooterSegment[];
39
+ /** Why the broker is offline, or that it is reconnecting. */
40
+ status: PaneFooterSegment[];
41
+ hints: PaneHint[];
42
+ }
43
+
44
+ const ONLINE: OfflineBrokerFooter = { asOf: [], status: [], hints: [] };
45
+
46
+ /**
47
+ * Footer status and key for a pane showing a broker that is offline: when its
48
+ * positions were synced, why it is offline, and `[b] reconnect`. Reconnecting
49
+ * does what Connect does in Brokers (a signed-in broker asks for a new
50
+ * sign-in, any other connects), then syncs the profile so the positions are
51
+ * current again. Nothing changes on screen until that sync lands.
52
+ */
53
+ export function useOfflineBrokerFooter(offline: OfflineBroker | null): OfflineBrokerFooter {
54
+ const { connectBrokerInstance, syncBrokerInstance } = usePluginBrokerActions();
55
+ const { notify } = usePluginAppActions();
56
+ const [reconnecting, setReconnecting] = useState(false);
57
+ const runningRef = useRef(false);
58
+ const offlineRef = useRef(offline);
59
+ offlineRef.current = offline;
60
+
61
+ const reconnect = useCallback(async () => {
62
+ const target = offlineRef.current;
63
+ if (!target?.reconnectable || runningRef.current) return;
64
+ runningRef.current = true;
65
+ setReconnecting(true);
66
+ try {
67
+ if (isSignedInBrokerProfile(target.instance)) {
68
+ if (!await requestBrokerSignIn(signedInBrokerForProfile(target.instance, target.brokerName))) return;
69
+ } else {
70
+ await connectBrokerInstance(target.instance.id);
71
+ }
72
+ await syncBrokerInstance(target.instance.id);
73
+ } catch (error) {
74
+ notify({
75
+ type: "error",
76
+ body: error instanceof Error ? error.message : tf("Could not reconnect {broker}.", { broker: target.brokerName }),
77
+ });
78
+ } finally {
79
+ runningRef.current = false;
80
+ setReconnecting(false);
81
+ }
82
+ }, [connectBrokerInstance, notify, syncBrokerInstance]);
83
+
84
+ return useMemo(() => {
85
+ if (!offline) return ONLINE;
86
+ // Dates the positions, not the quotes, so a shot that dates its quotes keeps it.
87
+ const asOf: PaneFooterSegment[] = offline.syncedAt != null
88
+ ? [{ id: "broker-as-of", asOf: false, parts: [{ text: formatBrokerSyncedAt(offline.syncedAt), tone: "muted" }] }]
89
+ : [];
90
+ const status: PaneFooterSegment[] = [reconnecting
91
+ ? { id: "broker-status", parts: [{ text: t("Reconnecting…"), tone: "muted" }] }
92
+ : { id: "broker-status", parts: [{ text: offline.reason, tone: "warning" }] }];
93
+ const hints: PaneHint[] = offline.reconnectable
94
+ ? [{
95
+ id: "reconnect-broker",
96
+ key: RECONNECT_BROKER_KEY,
97
+ label: " reconnect",
98
+ title: tf("Reconnect {broker}", { broker: offline.brokerName }),
99
+ onPress: () => { void reconnect(); },
100
+ disabled: reconnecting,
101
+ }]
102
+ : [];
103
+ return { asOf, status, hints };
104
+ }, [offline, reconnect, reconnecting]);
105
+ }
@@ -23,8 +23,11 @@ export type ReadGuard = <T>(read: Promise<T>) => Promise<T>;
23
23
  * then upgrade. The session's plan decides before anything loads. Reads go
24
24
  * through `guard`, so a refusal from the server (a plan that ended while the
25
25
  * app was open, an expired session) shows the same wall, not an error.
26
+ *
27
+ * `preview`: the pane opens on every plan, the server serving a preview
28
+ * without Pro (EXEC), so only a refusal puts up the wall.
26
29
  */
27
- export function useProFeatureWall(copy: ProFeatureWallCopy, size: { width: number; height: number }): {
30
+ export function useProFeatureWall(copy: ProFeatureWallCopy, size: { width: number; height: number }, options: { preview?: boolean } = {}): {
28
31
  wall: ReactNode;
29
32
  guard: ReadGuard;
30
33
  } {
@@ -39,12 +42,14 @@ export function useProFeatureWall(copy: ProFeatureWallCopy, size: { width: numbe
39
42
  throw error;
40
43
  }), []);
41
44
 
45
+ // A preview pane is walled by the server's answer alone, never by the plan up front.
46
+ const gated = !options.preview;
42
47
  let wall: ReactNode = null;
43
- if (!access.signedIn || refused === 401) {
48
+ if ((gated && !access.signedIn) || refused === 401) {
44
49
  wall = <SignInWall placement={`${copy.placement.replace(/-wall$/, "")}-signin`} {...size} symbol={symbol} exchange={exchange} action={copy.action} />;
45
- } else if (!access.emailVerified || refused === 403) {
50
+ } else if ((gated && !access.emailVerified) || refused === 403) {
46
51
  wall = <SignInWall placement={`${copy.placement.replace(/-wall$/, "")}-signin`} {...size} symbol={symbol} exchange={exchange} action={copy.action} needsVerification />;
47
- } else if (!access.hasProAccess || refused === 402) {
52
+ } else if ((gated && !access.hasProAccess) || refused === 402) {
48
53
  wall = <ProWall placement={copy.placement} title={copy.title} message={copy.message} {...size} symbol={symbol} exchange={exchange} />;
49
54
  }
50
55
  return { wall, guard };
@@ -13,6 +13,7 @@ import { apiClient } from "../../../api-client";
13
13
  import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
14
14
  import { httpFetch } from "../../../utils/http-transport";
15
15
  import { asRecord, finiteOrNull } from "../../../utils/guards";
16
+ import { logFailure } from "../../../utils/log-failure";
16
17
 
17
18
  const FORMS_13F_BASE_URL = "https://forms13f.com/api/v1";
18
19
  const FORM_PAGE_LIMIT = 100;
@@ -169,7 +170,9 @@ async function fetchForms13F<T>(
169
170
  slot.promise = promise;
170
171
  activeRequests.set(key, promise);
171
172
  if (cached != null && !options.forceRefresh) {
172
- void promise.catch(() => {});
173
+ void promise.catch((error: unknown) => {
174
+ if (!options.signal?.aborted) logFailure("thirteenf.api", "Refresh cached filing data failed")(error);
175
+ });
173
176
  return cached.value;
174
177
  }
175
178
  return promise;
@@ -13,6 +13,7 @@ import {
13
13
  } from "./api";
14
14
  import { isoDateDaysAgo, isoDateToday, isoDateYearsAgo } from "../../../utils/calendar-date";
15
15
  import { abortable, withDeadline } from "../../../utils/async-deadline";
16
+ import { logFailure } from "../../../utils/log-failure";
16
17
  import {
17
18
  buildBrowserRows,
18
19
  filingGapFor,
@@ -239,7 +240,10 @@ async function loadBrowserPage(
239
240
  }
240
241
  const found = await searchThirteenFFunds(trimmed, browserLimit, signal, pageApiOptions);
241
242
  const funds = oneFundPerCik(found);
242
- const topFunds = await listTopThirteenFFunds(quarter, browserLimit, signal, apiOptions).catch((): ThirteenFTopFund[] => []);
243
+ const topFunds = await listTopThirteenFFunds(quarter, browserLimit, signal, apiOptions).catch((error: unknown): ThirteenFTopFund[] => {
244
+ if (!signal?.aborted) logFailure("thirteenf.data", "Load performance ranks for fund search failed")(error);
245
+ return [];
246
+ });
243
247
  const topByCik = new Map(topFunds.map((fund) => [fund.cik, fund]));
244
248
  const matchedTopFunds = funds
245
249
  .map((fund) => topByCik.get(fund.cik))
@@ -647,7 +647,9 @@ export function createThirteenFHeadless(
647
647
  if (ctx.signal?.aborted) throw error;
648
648
  return [];
649
649
  });
650
- shadowed.catch(() => {});
650
+ shadowed.catch(() => {
651
+ // Intentional ignore: only cancellation reaches this guard; the awaited promise still rejects.
652
+ });
651
653
  }
652
654
  try {
653
655
  holdings = await loadTickerHoldings(ticker, Number(args.options.offset ?? 0), ctx.signal);
@@ -17,6 +17,7 @@ import {
17
17
  } from "../../../ui";
18
18
  import { isDetailBackNavigationKey } from "../../../utils/back-navigation";
19
19
  import { isPlainKey } from "../../../utils/keyboard";
20
+ import { logFailure } from "../../../utils/log-failure";
20
21
  import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
21
22
  import { nextHeaderSort } from "../../../utils/sort-values";
22
23
  import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../runtime";
@@ -118,7 +119,7 @@ function ThirteenFBrowserPane({ focused, width, height, onDetailChange }: PanePr
118
119
  let alive = true;
119
120
  void Promise.all(browser.pages.map((page) => page.reportsLoaded ?? Promise.resolve())).then(() => {
120
121
  if (alive) setReportPaint((value) => value + 1);
121
- }, () => {});
122
+ }, logFailure("thirteenf.pane", "Load filing metadata for fund rows failed"));
122
123
  return () => { alive = false; };
123
124
  }, [browser.pages]);
124
125
  const error = browser.error?.message ?? null;
@@ -193,7 +194,9 @@ function ThirteenFBrowserPane({ focused, width, height, onDetailChange }: PanePr
193
194
  // Every request behind a fund detail is cached per path, so warming it
194
195
  // while the cursor rests on the row makes Enter read from cache.
195
196
  const prefetchDetail = useCallback((row: FundBrowserRow) => {
196
- void loadFundDetail(row.cik, row.name).catch(() => {});
197
+ void loadFundDetail(row.cik, row.name).catch(() => {
198
+ // Intentional ignore: speculative prefetch is retried and failures are shown when the fund opens.
199
+ });
197
200
  }, []);
198
201
 
199
202
  const browserStatusInfo = useMemo<PaneFooterSegment[]>(() => [
@@ -376,7 +379,9 @@ export function FundDetailView({
376
379
  : Promise.resolve(new Map<string, number | null>())
377
380
  ).then((factors) => {
378
381
  if (abortRef.current === controller) setSplitFactors(factors);
379
- }, () => {});
382
+ }, () => {
383
+ // Intentional ignore: split lookups reject only on cancellation; individual failures remain unconfirmed.
384
+ });
380
385
  })
381
386
  .catch((loadError) => {
382
387
  if (abortRef.current !== controller) return;
@@ -0,0 +1,84 @@
1
+ import type { CloudMarketResponse, CloudPricePointPayload } from "../api-client/types";
2
+ import type { CloudHistoryParams } from "../api-client/paths";
3
+ import { fxLegForCurrency } from "../market-data/coordinator/fx-legs";
4
+ import { heldCurrencyUnit } from "../utils/currency-units";
5
+
6
+ /** Reads a pair's daily bars; `apiClient` in the app, a fake in tests. */
7
+ export interface CostFxHistoryClient {
8
+ getCloudHistory(symbol: string, exchange: string, params: CloudHistoryParams): Promise<CloudMarketResponse<CloudPricePointPayload[]>>;
9
+ }
10
+
11
+ /** The rate a lot's cost was bought at: one unit of its currency in the portfolio's, at a dated daily close. */
12
+ export interface CostFxReading {
13
+ rate: number;
14
+ /** The UTC day of the FX close it was read from: the purchase date, or the last trading day before it. */
15
+ date: string;
16
+ }
17
+
18
+ /** Why no rate could be read for a purchase date; the command says to give one with --cost-fx instead. */
19
+ export class CostFxError extends Error {
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = "CostFxError";
23
+ }
24
+ }
25
+
26
+ const DAY_MS = 86_400_000;
27
+ /** How far before the purchase date a close is looked for: a weekend and a holiday fit inside it, a gap in the data does not. */
28
+ const LOOKBACK_DAYS = 7;
29
+
30
+ const isoDay = (time: number) => new Date(time).toISOString().slice(0, 10);
31
+
32
+ /**
33
+ * USD per unit of an ISO currency at its last completed daily close on or
34
+ * before `date`, looking back a week at most. Today's bar, still trading, is
35
+ * never read. Null when the pair has no close in that week.
36
+ */
37
+ async function usdPerUnitOn(code: string, date: string, client: CostFxHistoryClient, now: Date): Promise<CostFxReading | null> {
38
+ if (code === "USD") return { rate: 1, date };
39
+ const leg = fxLegForCurrency(code);
40
+ if (!leg) throw new CostFxError(`No daily FX history is served for ${code}`);
41
+ const day = Date.parse(`${date}T00:00:00Z`);
42
+ const response = await client.getCloudHistory(leg.instrument.symbol, leg.instrument.exchange ?? "", {
43
+ interval: "1day", startDate: isoDay(day - LOOKBACK_DAYS * DAY_MS), endDate: isoDay(day + DAY_MS), outputsize: LOOKBACK_DAYS + 4,
44
+ });
45
+ if (response.status !== "success" && response.status !== "partial") return null;
46
+ const today = isoDay(now.getTime());
47
+ let best: CostFxReading | null = null;
48
+ for (const row of Array.isArray(response.data) ? response.data : []) {
49
+ const at = typeof row?.date === "string" ? Date.parse(row.date) : Number.NaN;
50
+ if (!Number.isFinite(at) || !Number.isFinite(row.close) || row.close <= 0) continue;
51
+ const rowDay = isoDay(at);
52
+ if (rowDay > date || rowDay >= today || Date.parse(`${rowDay}T00:00:00Z`) < day - LOOKBACK_DAYS * DAY_MS) continue;
53
+ if (!best || rowDay > best.date) best = { rate: leg.invert ? 1 / row.close : row.close, date: rowDay };
54
+ }
55
+ return best;
56
+ }
57
+
58
+ /**
59
+ * What one unit of `from` (a lot's currency, GBp counting pence and USX cents) cost in
60
+ * `into` (the portfolio's) at the daily FX closes of `date`, each pair's last
61
+ * close on or before it, crossed through USD as every converted value is.
62
+ * Throws `CostFxError` when a pair is not served or has no close in the week
63
+ * before the date.
64
+ */
65
+ export async function loadCostFxRate(
66
+ from: string,
67
+ into: string,
68
+ date: string,
69
+ client: CostFxHistoryClient,
70
+ now = new Date(),
71
+ ): Promise<CostFxReading> {
72
+ const fromUnit = heldCurrencyUnit(from);
73
+ const intoUnit = heldCurrencyUnit(into);
74
+ const [fromUsd, intoUsd] = await Promise.all([
75
+ usdPerUnitOn(fromUnit.currency, date, client, now),
76
+ usdPerUnitOn(intoUnit.currency, date, client, now),
77
+ ]);
78
+ const missing = [fromUsd ? null : fromUnit.currency, intoUsd ? null : intoUnit.currency].filter(Boolean);
79
+ if (!fromUsd || !intoUsd) throw new CostFxError(`No ${missing.join(" or ")} FX close in the week up to ${date}`);
80
+ const rate = (fromUsd.rate / fromUnit.divisor) / (intoUsd.rate / intoUnit.divisor);
81
+ if (!Number.isFinite(rate) || rate <= 0) throw new CostFxError(`No usable ${fromUnit.currency}/${intoUnit.currency} rate for ${date}`);
82
+ // A pair that did not trade that day (its holiday) dates the cross by its own last close.
83
+ return { rate, date: fromUsd.date < intoUsd.date ? fromUsd.date : intoUsd.date };
84
+ }