Browse Source

Merge 7754790029 into eaa4c3e6ef

pull/8233/merge
PJ 2 days ago
committed by GitHub
parent
commit
3f26365ea6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1735
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.spec.ts
  2. 596
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  3. 15
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

1735
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.spec.ts

File diff suppressed because it is too large

596
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -1,28 +1,197 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInCalendarDays } from 'date-fns';
interface DatedCashFlow {
date: string;
value: Big;
}
/**
* Money-Weighted Rate of Return (MWR): the annualized internal rate of return
* of the dated investor cash flows and the terminal portfolio value.
*/
export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
protected calculateOverallPerformance({
accumulatedValuesByDate,
positions
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
positions: PortfolioCalculatorHolding[];
}): PortfolioSnapshot {
const latestAccumulatedValues = Object.values(accumulatedValuesByDate).at(
-1
);
return this.getOverallPerformance({
investedCapital:
latestAccumulatedValues?.totalInvestmentValue ?? new Big(0),
investedCapitalWithCurrencyEffect:
latestAccumulatedValues?.totalInvestmentValueWithCurrencyEffect ??
new Big(0),
positions
});
}
protected calculatePerformancePercentages(): {
[date: string]: PerformancePercentages;
} {
throw new Error('Method not implemented.');
protected calculatePerformancePercentages({
accumulatedValuesByDate
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
for (const [date, accumulatedValues] of Object.entries(
accumulatedValuesByDate
)) {
const netPerformanceInPercentageWithCurrencyEffect =
this.getMoneyWeightedReturn({
date,
terminalValue: accumulatedValues.totalCurrentValueWithCurrencyEffect,
withCurrencyEffect: true
});
const netPerformanceInPercentageWithoutDividendsWithCurrencyEffect =
this.getMoneyWeightedReturn({
date,
includeDividends: false,
terminalValue: accumulatedValues.totalCurrentValueWithCurrencyEffect,
withCurrencyEffect: true
});
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect -
netPerformanceInPercentageWithoutDividendsWithCurrencyEffect,
netPerformanceInPercentage: this.getMoneyWeightedReturn({
date,
terminalValue: accumulatedValues.totalCurrentValue,
withCurrencyEffect: false
}),
netPerformanceInPercentageWithCurrencyEffect
};
}
return performancePercentagesByDate;
}
protected calculatePerformancePercentagesForDateRange(): {
[date: string]: PerformancePercentages;
} {
throw new Error('Method not implemented.');
protected calculatePerformancePercentagesForDateRange({
historicalDataItems
}: {
historicalDataItems: HistoricalDataItem[];
}): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
const firstHistoricalDataItem = historicalDataItems[0];
if (!firstHistoricalDataItem) {
return performancePercentagesByDate;
}
const openingDate = firstHistoricalDataItem.date;
const openingValue = new Big(firstHistoricalDataItem.value ?? 0);
const openingValueWithCurrencyEffect = new Big(
firstHistoricalDataItem.valueWithCurrencyEffect ?? 0
);
for (const historicalDataItem of historicalDataItems) {
const cashFlows = [
{
date: openingDate,
value: openingValue.mul(-1)
},
...this.getActivityCashFlows({
endDate: historicalDataItem.date,
startDate: openingDate,
withCurrencyEffect: false
}),
{
date: historicalDataItem.date,
value: new Big(historicalDataItem.value ?? 0)
}
];
const cashFlowsWithCurrencyEffect = [
{
date: openingDate,
value: openingValueWithCurrencyEffect.mul(-1)
},
...this.getActivityCashFlows({
endDate: historicalDataItem.date,
startDate: openingDate,
withCurrencyEffect: true
}),
{
date: historicalDataItem.date,
value: new Big(historicalDataItem.valueWithCurrencyEffect ?? 0)
}
];
const cashFlowsWithoutDividendsWithCurrencyEffect = [
{
date: openingDate,
value: openingValueWithCurrencyEffect.mul(-1)
},
...this.getActivityCashFlows({
endDate: historicalDataItem.date,
includeDividends: false,
startDate: openingDate,
withCurrencyEffect: true
}),
{
date: historicalDataItem.date,
value: new Big(historicalDataItem.valueWithCurrencyEffect ?? 0)
}
];
const netPerformanceInPercentageWithCurrencyEffect = this.calculateXirr(
cashFlowsWithCurrencyEffect
);
const netPerformanceInPercentageWithoutDividendsWithCurrencyEffect =
this.calculateXirr(cashFlowsWithoutDividendsWithCurrencyEffect);
performancePercentagesByDate[historicalDataItem.date] = {
dividendInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect -
netPerformanceInPercentageWithoutDividendsWithCurrencyEffect,
netPerformanceInPercentage: this.calculateXirr(cashFlows),
netPerformanceInPercentageWithCurrencyEffect
};
}
return performancePercentagesByDate;
}
protected getHoldingPerformance({}: {
protected getHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
@ -31,10 +200,411 @@ export class MwrPortfolioCalculator extends PortfolioCalculator {
};
start: Date;
} & AssetProfileIdentifier): HoldingPerformance {
throw new Error('Method not implemented.');
const { holdingPerformance, valuation } =
this.getMethodIndependentHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
});
if (!valuation) {
return holdingPerformance;
}
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
const activities =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const endDate = chartDates.at(-1);
if (!endDate) {
return holdingPerformance;
}
const terminalValue = valuation.currentValues[endDate] ?? new Big(0);
const terminalValueWithCurrencyEffect =
valuation.currentValuesWithCurrencyEffect[endDate] ?? new Big(0);
const grossPerformancePercentage = this.calculateXirr([
...this.getActivityCashFlows({
activities,
endDate,
includeFees: false,
withCurrencyEffect: false
}),
{
date: endDate,
value: terminalValue
}
]);
const grossPerformancePercentageWithCurrencyEffect = this.calculateXirr([
...this.getActivityCashFlows({
activities,
endDate,
includeFees: false,
withCurrencyEffect: true
}),
{
date: endDate,
value: terminalValueWithCurrencyEffect
}
]);
const netPerformancePercentage = this.calculateXirr([
...this.getActivityCashFlows({
activities,
endDate,
includeFees: true,
withCurrencyEffect: false
}),
{
date: endDate,
value: terminalValue
}
]);
const netPerformancePercentageWithCurrencyEffect = this.calculateXirr([
...this.getActivityCashFlows({
activities,
endDate,
includeFees: true,
withCurrencyEffect: true
}),
{
date: endDate,
value: terminalValueWithCurrencyEffect
}
]);
const netPerformancePercentageWithCurrencyEffectMap: HoldingPerformance['netPerformancePercentageWithCurrencyEffectMap'] =
{
...holdingPerformance.netPerformancePercentageWithCurrencyEffectMap,
max: new Big(netPerformancePercentageWithCurrencyEffect)
};
for (const {
dateRange,
endDateString: rangeEndDateString,
startDateString: rangeStartDateString
} of valuation.dateRanges) {
if (dateRange === 'max') {
continue;
}
const openingValueWithCurrencyEffect =
valuation.currentValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const terminalValueOfRangeWithCurrencyEffect =
valuation.currentValuesWithCurrencyEffect[rangeEndDateString] ??
new Big(0);
const rangeReturnWithCurrencyEffect = this.calculateXirr([
{
date: rangeStartDateString,
value: openingValueWithCurrencyEffect.mul(-1)
},
...this.getActivityCashFlows({
activities,
endDate: rangeEndDateString,
includeFees: true,
startDate: rangeStartDateString,
withCurrencyEffect: true
}),
{
date: rangeEndDateString,
value: terminalValueOfRangeWithCurrencyEffect
}
]);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = new Big(
rangeReturnWithCurrencyEffect
);
}
return {
...holdingPerformance,
grossPerformancePercentage: new Big(grossPerformancePercentage),
grossPerformancePercentageWithCurrencyEffect: new Big(
grossPerformancePercentageWithCurrencyEffect
),
netPerformancePercentage: new Big(netPerformancePercentage),
netPerformancePercentageWithCurrencyEffectMap
};
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.MWR;
}
private calculateXirr(cashFlows: DatedCashFlow[]): number {
const cashFlowsByDate = new Map<string, Big>();
for (const { date, value } of cashFlows) {
cashFlowsByDate.set(
date,
(cashFlowsByDate.get(date) ?? new Big(0)).plus(value)
);
}
const aggregatedCashFlows = Array.from(cashFlowsByDate.entries())
.map(([date, value]) => ({ date, value }))
.filter(({ value }) => !value.eq(0))
.sort((a, b) => a.date.localeCompare(b.date));
if (aggregatedCashFlows.length < 2) {
return 0;
}
if (
!aggregatedCashFlows.some(({ value }) => value.lt(0)) ||
!aggregatedCashFlows.some(({ value }) => value.gt(0))
) {
return 0;
}
const firstDate = parseDate(aggregatedCashFlows[0].date);
const lastDate = parseDate(aggregatedCashFlows.at(-1).date);
if (differenceInCalendarDays(lastDate, firstDate) === 0) {
return 0;
}
const scale = aggregatedCashFlows.reduce((maximum, { value }) => {
return value.abs().gt(maximum) ? value.abs() : maximum;
}, new Big(0));
if (scale.eq(0)) {
return 0;
}
const normalizedCashFlows = aggregatedCashFlows.map(({ date, value }) => ({
date,
value: value.div(scale).toNumber()
}));
const calculateXnpv = (rate: number) => {
if (rate <= -1) {
return Number.POSITIVE_INFINITY;
}
return normalizedCashFlows.reduce((xnpv, cashFlow) => {
const days = differenceInCalendarDays(
parseDate(cashFlow.date),
firstDate
);
return xnpv + cashFlow.value / Math.pow(1 + rate, days / 365);
}, 0);
};
// Start Newton's method at zero. In common multiple-IRR cases this
// deterministically favors the root reached from that initial estimate.
let newtonRate = 0;
for (let iteration = 0; iteration < 50; iteration += 1) {
const xnpv = calculateXnpv(newtonRate);
if (Math.abs(xnpv) < 1e-14) {
return newtonRate;
}
const derivative = normalizedCashFlows.reduce((value, cashFlow) => {
const days = differenceInCalendarDays(
parseDate(cashFlow.date),
firstDate
);
const exponent = days / 365;
if (exponent === 0) {
return value;
}
return (
value -
(exponent * cashFlow.value) / Math.pow(1 + newtonRate, exponent + 1)
);
}, 0);
if (!Number.isFinite(derivative) || Math.abs(derivative) < 1e-14) {
break;
}
const nextRate = newtonRate - xnpv / derivative;
if (!Number.isFinite(nextRate) || nextRate <= -1) {
break;
}
if (Math.abs(nextRate - newtonRate) < 1e-14) {
return nextRate;
}
newtonRate = nextRate;
}
let lowerRate = -0.999999999;
let upperRate = 1;
let lowerValue = calculateXnpv(lowerRate);
let upperValue = calculateXnpv(upperRate);
if (Math.abs(lowerValue) < 1e-14) {
return lowerRate;
}
if (Math.abs(upperValue) < 1e-14) {
return upperRate;
}
while (
Math.sign(lowerValue) === Math.sign(upperValue) &&
upperRate < 1_000_000
) {
upperRate = upperRate * 2 + 1;
upperValue = calculateXnpv(upperRate);
}
if (
!Number.isFinite(lowerValue) ||
!Number.isFinite(upperValue) ||
Math.sign(lowerValue) === Math.sign(upperValue)
) {
return 0;
}
for (let iteration = 0; iteration < 200; iteration += 1) {
const middleRate = (lowerRate + upperRate) / 2;
const middleValue = calculateXnpv(middleRate);
if (Math.abs(middleValue) < 1e-14) {
return middleRate;
}
if (Math.sign(lowerValue) === Math.sign(middleValue)) {
lowerRate = middleRate;
lowerValue = middleValue;
} else {
upperRate = middleRate;
upperValue = middleValue;
}
if (Math.abs(upperRate - lowerRate) < 1e-14) {
return (lowerRate + upperRate) / 2;
}
}
return (lowerRate + upperRate) / 2;
}
private getActivityCashFlows({
activities = this.activities,
endDate,
includeDividends = true,
includeFees = true,
startDate,
withCurrencyEffect
}: {
activities?: PortfolioCalculatorActivity[];
endDate: string;
includeDividends?: boolean;
includeFees?: boolean;
startDate?: string;
withCurrencyEffect: boolean;
}): DatedCashFlow[] {
const cashFlows: DatedCashFlow[] = [];
const exchangeRatesByCurrency = this.getExchangeRatesByCurrency();
for (const activity of activities) {
const isCashInBaseCurrency =
activity.assetProfile.assetSubClass === AssetSubClass.CASH &&
activity.assetProfile.currency === this.getBaseCurrency() &&
activity.assetProfile.symbol === this.getBaseCurrency();
if (
isCashInBaseCurrency ||
activity.date > endDate ||
(startDate !== undefined && activity.date <= startDate)
) {
continue;
}
const currencyPair =
activity.assetProfile.currency + this.getBaseCurrency();
const exchangeRates = exchangeRatesByCurrency[currencyPair];
const exchangeRate = withCurrencyEffect
? (exchangeRates?.[activity.date] ?? 1)
: (exchangeRates?.[endDate] ?? 1);
if (includeFees && !activity.fee.eq(0)) {
cashFlows.push({
date: activity.date,
value: activity.fee.mul(exchangeRate).mul(-1)
});
}
if (['BUY', 'SELL'].includes(activity.type)) {
const transactionValue = activity.quantity
.mul(activity.unitPrice)
.mul(exchangeRate);
cashFlows.push({
date: activity.date,
value:
activity.type === 'BUY'
? transactionValue.mul(-1)
: transactionValue
});
continue;
}
if (
activity.type === 'INTEREST' ||
(includeDividends && activity.type === 'DIVIDEND')
) {
cashFlows.push({
date: activity.date,
value: activity.quantity.mul(activity.unitPrice).mul(exchangeRate)
});
}
}
return cashFlows;
}
private getMoneyWeightedReturn({
date,
includeDividends = true,
terminalValue,
withCurrencyEffect
}: {
date: string;
includeDividends?: boolean;
terminalValue: Big;
withCurrencyEffect: boolean;
}): number {
return this.calculateXirr([
...this.getActivityCashFlows({
endDate: date,
includeDividends,
withCurrencyEffect
}),
{
date,
value: terminalValue
}
]);
}
}

15
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -101,6 +101,9 @@ export abstract class PortfolioCalculator {
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private exchangeRatesByCurrency: {
[currencyPair: string]: { [date: string]: number };
} = {};
private filters: Filter[];
private holdingBalancesByDate: HoldingBalancesAtDate[];
private portfolioSnapshotService: PortfolioSnapshotService;
@ -308,7 +311,7 @@ export abstract class PortfolioCalculator {
currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency;
}
const exchangeRatesByCurrency =
this.exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))),
endDate: this.endDate,
@ -316,6 +319,8 @@ export abstract class PortfolioCalculator {
targetCurrency: this.currency
});
const exchangeRatesByCurrency = this.exchangeRatesByCurrency;
const {
dataProviderInfos,
errors: currentRateErrors,
@ -905,6 +910,14 @@ export abstract class PortfolioCalculator {
return this.dataProviderInfos;
}
protected getBaseCurrency() {
return this.currency;
}
protected getExchangeRatesByCurrency() {
return this.exchangeRatesByCurrency;
}
protected getDividendInBaseCurrencyOfHoldings(
holdings: PortfolioSnapshotHolding[]
) {

Loading…
Cancel
Save