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
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
|
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 [
|
|
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
|
|
89
|
-
* sign-in lapsed
|
|
90
|
-
* is the only word of why the
|
|
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
|
-
|
|
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
|
|
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,
|
|
123
|
-
[accountState,
|
|
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
|
+
}
|