Browse Source

Extract shared logic of portfolio calculators

pull/8206/head
Thomas Kaul 7 hours ago
parent
commit
e4dfe6f5e9
  1. 532
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 602
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  3. 159
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts
  4. 614
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  5. 7
      apps/api/src/app/portfolio/interfaces/date-range-interval.interface.ts
  6. 14
      apps/api/src/app/portfolio/interfaces/holding-performance-valuation.interface.ts
  7. 6
      apps/api/src/app/portfolio/interfaces/invested-capital.interface.ts
  8. 7
      apps/api/src/app/portfolio/interfaces/method-independent-holding-performance.interface.ts

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

@ -1,11 +1,14 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { DateRangeInterval } from '@ghostfolio/api/app/portfolio/interfaces/date-range-interval.interface';
import { HoldingBalance } from '@ghostfolio/api/app/portfolio/interfaces/holding-balance.interface'; import { HoldingBalance } from '@ghostfolio/api/app/portfolio/interfaces/holding-balance.interface';
import { HoldingBalancesAtDate } from '@ghostfolio/api/app/portfolio/interfaces/holding-balances-at-date.interface'; import { HoldingBalancesAtDate } from '@ghostfolio/api/app/portfolio/interfaces/holding-balances-at-date.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { HoldingValuationItem } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation-item.interface'; import { HoldingValuationItem } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation-item.interface';
import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface'; import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface';
import { InvestedCapital } from '@ghostfolio/api/app/portfolio/interfaces/invested-capital.interface';
import { MethodIndependentHoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/method-independent-holding-performance.interface';
import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface'; import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface';
import { PortfolioCalculatorActivity } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity.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 { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
@ -18,7 +21,10 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import { import {
INVESTMENT_ACTIVITY_TYPES, INVESTMENT_ACTIVITY_TYPES,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
@ -47,7 +53,7 @@ import {
PortfolioSnapshot, PortfolioSnapshot,
PortfolioSnapshotHolding PortfolioSnapshotHolding
} from '@ghostfolio/common/models'; } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types'; import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -66,13 +72,14 @@ import {
isBefore, isBefore,
isFuture, isFuture,
isPast, isPast,
isThisYear,
isWithinInterval, isWithinInterval,
min, min,
startOfDay, startOfDay,
startOfYear, startOfYear,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { groupBy, sortBy, uniqBy } from 'lodash-es'; import { groupBy, mapValues, sortBy, uniqBy } from 'lodash-es';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
@ -218,9 +225,13 @@ export abstract class PortfolioCalculator {
this.snapshotPromise.catch(() => undefined); this.snapshotPromise.catch(() => undefined);
} }
protected abstract calculateOverallPerformance( protected abstract calculateOverallPerformance({
positions: PortfolioCalculatorHolding[] accumulatedValuesByDate,
): PortfolioSnapshot; positions
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
positions: PortfolioCalculatorHolding[];
}): PortfolioSnapshot;
protected abstract calculatePerformancePercentages({ protected abstract calculatePerformancePercentages({
accumulatedValuesByDate accumulatedValuesByDate
@ -727,7 +738,10 @@ export abstract class PortfolioCalculator {
}; };
}); });
const overall = this.calculateOverallPerformance(positions); const overall = this.calculateOverallPerformance({
accumulatedValuesByDate,
positions
});
return { return {
...overall, ...overall,
@ -965,6 +979,80 @@ export abstract class PortfolioCalculator {
start: Date; start: Date;
} & AssetProfileIdentifier): HoldingPerformance; } & AssetProfileIdentifier): HoldingPerformance;
/**
* Adds the values which depend on the invested capital of the performance
* calculation type to the holding performance: the percentages and the
* dividend yield. The average investment fields keep their names and hold
* the invested capital of each type.
*/
protected getHoldingPerformanceWithInvestedCapital({
daysInMarket,
holdingPerformance,
investedCapital,
investedCapitalByDateRange,
investedCapitalValues = {},
investedCapitalValuesWithCurrencyEffect = {},
investedCapitalWithCurrencyEffect
}: {
daysInMarket: number;
holdingPerformance: HoldingPerformance;
investedCapitalByDateRange: { [key: DateRange]: Big };
investedCapitalValues?: { [date: string]: Big };
investedCapitalValuesWithCurrencyEffect?: { [date: string]: Big };
} & InvestedCapital): HoldingPerformance {
const {
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformanceWithCurrencyEffectMap,
totalDividendInBaseCurrency
} = holdingPerformance;
const netPerformancePercentageWithCurrencyEffectMap = mapValues(
netPerformanceWithCurrencyEffectMap,
(netPerformanceOfDateRange, dateRange) => {
const investedCapitalOfDateRange =
investedCapitalByDateRange[dateRange] ?? new Big(0);
return investedCapitalOfDateRange.gt(0)
? netPerformanceOfDateRange.div(investedCapitalOfDateRange)
: new Big(0);
}
);
return {
...holdingPerformance,
netPerformancePercentageWithCurrencyEffectMap,
averageInvestment: investedCapital,
averageInvestmentValues: investedCapitalValues,
averageInvestmentValuesWithCurrencyEffect:
investedCapitalValuesWithCurrencyEffect,
averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect,
dividendYieldPercent: this.getDividendYieldPercent({
daysInMarket,
investedCapital,
dividendInBaseCurrency: totalDividendInBaseCurrency
}),
dividendYieldPercentWithCurrencyEffect: this.getDividendYieldPercent({
daysInMarket,
dividendInBaseCurrency: totalDividendInBaseCurrency,
investedCapital: investedCapitalWithCurrencyEffect
}),
grossPerformancePercentage: investedCapital.gt(0)
? grossPerformance.div(investedCapital)
: new Big(0),
grossPerformancePercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.gt(0)
? grossPerformanceWithCurrencyEffect.div(
investedCapitalWithCurrencyEffect
)
: new Big(0),
netPerformancePercentage: investedCapital.gt(0)
? netPerformance.div(investedCapital)
: new Big(0)
};
}
protected getHoldingValuation({ protected getHoldingValuation({
activities, activities,
exchangeRates, exchangeRates,
@ -1406,6 +1494,354 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalLiabilitiesWithCurrencyEffect; return this.snapshot.totalLiabilitiesWithCurrencyEffect;
} }
/**
* Returns the performance of a holding without the values which depend on
* the performance calculation type: the average investment, the percentages
* and the dividend yield. The valuation is missing if the holding has no
* activities or no market price.
*/
protected getMethodIndependentHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): MethodIndependentHoldingPerformance {
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
let activities: PortfolioCalculatorActivityItem[] =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH';
if (activities.length <= 0) {
return { holdingPerformance: this.getEmptyHoldingPerformance() };
}
// The dividends, the interest and the liabilities are derived from the
// activities only. Accumulate them upfront so that they survive the bail
// out for symbols without a market price below.
const {
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency
} = this.getTotalsFromActivities({ activities, exchangeRates });
const dateOfFirstActivity = parseDate(activities[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[assetProfileIdentifier];
const unitPriceAtEndDate = this.getUnitPriceAtEndDate({
activities,
dataSource,
isCash,
marketPriceAtEndDate:
marketSymbolMap[endDateString]?.[assetProfileIdentifier]
});
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start))
) {
// A missing market price can only affect the quantity which is held. The
// dividends, the interest and the liabilities do not hold any quantity
// and are therefore not in error.
const hasActivitiesWithQuantity = activities.some(({ type }) => {
return ['BUY', 'SELL'].includes(type);
});
return {
holdingPerformance: {
...this.getEmptyHoldingPerformance(),
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency,
hasErrors: hasActivitiesWithQuantity
}
};
}
activities = this.getActivitiesWithMarketPrices({
activities,
chartDates,
endDateString,
marketSymbolMap,
startDateString,
unitPriceAtEndDate,
unitPriceAtStartDate,
assetProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
}
});
const holdingValuation = this.getHoldingValuation({
activities,
exchangeRates,
unitPriceAtStartDate
});
const {
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
initialValue,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
items,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect
} = holdingValuation;
const indexOfStartActivity = items.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndActivity = items.findIndex(({ itemType }) => {
return itemType === 'end';
});
const {
fees: feesAtStartDate,
grossPerformance: grossPerformanceAtStartDate,
grossPerformanceWithCurrencyEffect:
grossPerformanceAtStartDateWithCurrencyEffect
} = items[indexOfStartActivity];
const {
fees,
grossPerformance,
grossPerformanceWithCurrencyEffect,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
quantity: totalQuantity
} = items[indexOfEndActivity];
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = totalGrossPerformance.minus(
fees.minus(feesAtStartDate)
);
const dateRanges = (
[
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[]
).map((dateRange): DateRangeInterval => {
const dateInterval = getIntervalFromDateRange({ dateRange });
let startDateOfDateRange = dateInterval.startDate;
if (isBefore(startDateOfDateRange, start)) {
startDateOfDateRange = start;
}
return {
dateRange,
endDateString: format(dateInterval.endDate, DATE_FORMAT),
startDateString: format(startDateOfDateRange, DATE_FORMAT)
};
});
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const {
dateRange,
endDateString: rangeEndDateString,
startDateString: rangeStartDateString
} of dateRanges) {
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
}
return {
holdingPerformance: {
...this.getEmptyHoldingPerformance(),
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors:
totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
},
valuation: {
...holdingValuation,
activities,
dateRanges,
indexOfEndActivity,
indexOfStartActivity,
unitPriceAtEndDate,
daysInMarket: differenceInDays(new Date(), dateOfFirstActivity)
}
};
}
/**
* Returns the overall performance of the holdings. The invested capital of
* the performance calculation type is the denominator of the dividend yield.
*/
protected getOverallPerformance({
investedCapital,
investedCapitalWithCurrencyEffect,
positions
}: {
positions: PortfolioCalculatorHolding[];
} & InvestedCapital): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let hasErrors = false;
let totalFeesWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
for (const {
feeInBaseCurrency,
grossPerformance,
includeInPerformance,
investment,
investmentWithCurrencyEffect,
quantity,
valueInBaseCurrency
} of positions) {
currentValueInBaseCurrency =
currentValueInBaseCurrency.plus(valueInBaseCurrency);
if (!includeInPerformance) {
continue;
}
totalFeesWithCurrencyEffect =
totalFeesWithCurrencyEffect.plus(feeInBaseCurrency);
totalInvestment = totalInvestment.plus(investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(investmentWithCurrencyEffect);
if (!grossPerformance && !quantity.eq(0)) {
hasErrors = true;
}
}
const dateOfFirstActivity = this.getStartDate();
const daysInMarket = dateOfFirstActivity
? differenceInDays(new Date(), dateOfFirstActivity)
: 0;
const totalDividendInBaseCurrency =
this.getDividendInBaseCurrencyOfHoldings(positions);
// A holding without a market price, and a holding which is excluded from
// the performance, gives a dividend but no invested capital. Such a
// holding makes the dividend yield too high. Therefore the dividend yield
// stays 0 in this case.
const hasDividendWithoutInvestedCapital = positions.some(
({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => {
return (
!dividendInBaseCurrency.eq(0) &&
(!includeInPerformance || averageInvestment.eq(0))
);
}
);
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
dividendYieldPercent: this.getDividendYieldPercent({
daysInMarket,
dividendInBaseCurrency: totalDividendInBaseCurrency,
investedCapital: hasDividendWithoutInvestedCapital
? new Big(0)
: investedCapital
}),
dividendYieldPercentWithCurrencyEffect: this.getDividendYieldPercent({
daysInMarket,
dividendInBaseCurrency: totalDividendInBaseCurrency,
investedCapital: hasDividendWithoutInvestedCapital
? new Big(0)
: investedCapitalWithCurrencyEffect
}),
errors: [],
historicalData: [],
totalCashInBaseCurrency: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0)
};
}
public async getPerformance({ end, start }: { end: Date; start: Date }) { public async getPerformance({ end, start }: { end: Date; start: Date }) {
await this.snapshotPromise; await this.snapshotPromise;
@ -1471,6 +1907,71 @@ export abstract class PortfolioCalculator {
protected abstract getPerformanceCalculationType(): PerformanceCalculationType; protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
/**
* Returns the performance percentages of a date of the snapshot chart with
* the invested capital of the performance calculation type
*/
protected getPerformancePercentagesOfAccumulatedValues({
accumulatedValues: {
totalDividendValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect
},
investedCapital,
investedCapitalWithCurrencyEffect
}: {
accumulatedValues: AccumulatedValues;
} & InvestedCapital): PerformancePercentages {
return {
dividendInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(investedCapitalWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: investedCapital.eq(0)
? 0
: totalNetPerformanceValue.div(investedCapital).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(investedCapitalWithCurrencyEffect)
.toNumber()
};
}
/**
* Returns the performance percentages of an item of a date range with the
* invested capital of the performance calculation type
*/
protected getPerformancePercentagesOfHistoricalDataItem({
historicalDataItem: {
dividendInBaseCurrency,
netPerformance,
netPerformanceWithCurrencyEffect
},
investedCapital,
investedCapitalWithCurrencyEffect
}: {
historicalDataItem: HistoricalDataItem;
investedCapital: number;
investedCapitalWithCurrencyEffect: number;
}): PerformancePercentages {
return {
dividendInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect > 0
? dividendInBaseCurrency / investedCapitalWithCurrencyEffect
: 0,
netPerformanceInPercentage:
investedCapital > 0 ? netPerformance / investedCapital : 0,
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect > 0
? netPerformanceWithCurrencyEffect / investedCapitalWithCurrencyEffect
: 0
};
}
public async getSnapshot() { public async getSnapshot() {
await this.snapshotPromise; await this.snapshotPromise;
@ -1818,6 +2319,23 @@ export abstract class PortfolioCalculator {
return chartDateMap; return chartDateMap;
} }
private getDividendYieldPercent({
daysInMarket,
dividendInBaseCurrency,
investedCapital
}: {
daysInMarket: number;
dividendInBaseCurrency: Big;
investedCapital: Big;
}) {
return getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: investedCapital.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(investedCapital)
});
}
@LogPerformance @LogPerformance
private async initialize(attempt = 1) { private async initialize(attempt = 1) {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();

602
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -2,19 +2,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { AverageInvestmentItem } from '@ghostfolio/api/app/portfolio/interfaces/average-investment-item.interface'; import { AverageInvestmentItem } from '@ghostfolio/api/app/portfolio/interfaces/average-investment-item.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface'; import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
import { WeightedInvestment } from '@ghostfolio/api/app/portfolio/interfaces/weighted-investment.interface'; import { WeightedInvestment } from '@ghostfolio/api/app/portfolio/interfaces/weighted-investment.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type'; import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { import { getSum, parseDate } from '@ghostfolio/common/helper';
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HistoricalDataItem HistoricalDataItem
@ -24,144 +15,39 @@ import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import { differenceInDays } from 'date-fns';
differenceInDays, import { mapValues, sortedIndex } from 'lodash-es';
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
import { sortedIndex } from 'lodash-es';
export class RoaiPortfolioCalculator extends PortfolioCalculator { export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance( protected calculateOverallPerformance({
positions: PortfolioCalculatorHolding[] positions
): PortfolioSnapshot { }: {
let currentValueInBaseCurrency = new Big(0); accumulatedValuesByDate: { [date: string]: AccumulatedValues };
let hasErrors = false; positions: PortfolioCalculatorHolding[];
let totalAverageInvestment = new Big(0); }): PortfolioSnapshot {
let totalAverageInvestmentWithCurrencyEffect = new Big(0); // The invested capital of the portfolio is the sum of the average
let totalFeesWithCurrencyEffect = new Big(0); // investments of the holdings
const totalInterestWithCurrencyEffect = new Big(0); const positionsIncludedInPerformance = positions.filter(
let totalInvestment = new Big(0); ({ includeInPerformance }) => {
let totalInvestmentWithCurrencyEffect = new Big(0); return includeInPerformance;
for (const currentPosition of positions) {
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (!currentPosition.includeInPerformance) {
continue;
}
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (
!currentPosition.grossPerformance &&
!currentPosition.quantity.eq(0)
) {
hasErrors = true;
}
if (currentPosition.averageInvestment) {
totalAverageInvestment = totalAverageInvestment.plus(
currentPosition.averageInvestment
);
totalAverageInvestmentWithCurrencyEffect =
totalAverageInvestmentWithCurrencyEffect.plus(
currentPosition.averageInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
);
hasErrors = true;
}
}
const dateOfFirstActivity = this.getStartDate();
const daysInMarket = dateOfFirstActivity
? differenceInDays(new Date(), dateOfFirstActivity)
: 0;
const totalDividendInBaseCurrency =
this.getDividendInBaseCurrencyOfHoldings(positions);
// A holding without a market price, and a holding which is excluded from
// the performance, gives a dividend but no average investment. Such a
// holding makes the dividend yield too high. Therefore the dividend yield
// stays 0 in this case.
const hasDividendWithoutAverageInvestment = positions.some(
({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => {
return (
!dividendInBaseCurrency.eq(0) &&
(!includeInPerformance || averageInvestment.eq(0))
);
} }
); );
const dividendYieldPercent = getAnnualizedPerformancePercent({ return this.getOverallPerformance({
daysInMarket,
netPerformancePercentage:
hasDividendWithoutAverageInvestment || totalAverageInvestment.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(totalAverageInvestment)
});
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage:
hasDividendWithoutAverageInvestment ||
totalAverageInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(
totalAverageInvestmentWithCurrencyEffect
)
});
return {
currentValueInBaseCurrency,
dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect,
hasErrors,
positions, positions,
totalFeesWithCurrencyEffect, investedCapital: getSum(
totalInterestWithCurrencyEffect, positionsIncludedInPerformance.map(({ averageInvestment }) => {
totalInvestment, return averageInvestment;
totalInvestmentWithCurrencyEffect, })
activitiesCount: this.activities.filter(({ type }) => { ),
return ['BUY', 'SELL'].includes(type); investedCapitalWithCurrencyEffect: getSum(
}).length, positionsIncludedInPerformance.map(
createdAt: new Date(), ({ averageInvestmentWithCurrencyEffect }) => {
errors: [], return averageInvestmentWithCurrencyEffect;
historicalData: [], }
totalCashInBaseCurrency: new Big(0), )
totalLiabilitiesWithCurrencyEffect: new Big(0) )
}; });
} }
protected calculatePerformancePercentages({ protected calculatePerformancePercentages({
@ -169,42 +55,14 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}: { }: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues }; accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } { }): { [date: string]: PerformancePercentages } {
const performancePercentagesByDate: { return mapValues(accumulatedValuesByDate, (accumulatedValues) => {
[date: string]: PerformancePercentages; return this.getPerformancePercentagesOfAccumulatedValues({
} = {}; accumulatedValues,
investedCapital: accumulatedValues.totalAverageInvestmentValue,
for (const [ investedCapitalWithCurrencyEffect:
date, accumulatedValues.totalAverageInvestmentValueWithCurrencyEffect
{ });
totalAverageInvestmentValue, });
totalAverageInvestmentValueWithCurrencyEffect,
totalDividendValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect
}
] of Object.entries(accumulatedValuesByDate)) {
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
totalAverageInvestmentValueWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(totalAverageInvestmentValueWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: totalAverageInvestmentValue.eq(0)
? 0
: totalNetPerformanceValue
.div(totalAverageInvestmentValue)
.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
totalAverageInvestmentValueWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalAverageInvestmentValueWithCurrencyEffect)
.toNumber()
};
}
return performancePercentagesByDate;
} }
protected calculatePerformancePercentagesForDateRange({ protected calculatePerformancePercentagesForDateRange({
@ -258,31 +116,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}); });
for (const [index, historicalDataItem] of historicalDataItems.entries()) { for (const [index, historicalDataItem] of historicalDataItems.entries()) {
const averageInvestmentValue = this.getAverageInvestment( performancePercentagesByDate[historicalDataItem.date] =
weightedInvestments[index] this.getPerformancePercentagesOfHistoricalDataItem({
).toNumber(); historicalDataItem,
investedCapital: this.getAverageInvestment(
const averageInvestmentValueWithCurrencyEffect = weightedInvestments[index]
this.getAverageInvestment( ).toNumber(),
weightedInvestmentsWithCurrencyEffect[index] investedCapitalWithCurrencyEffect: this.getAverageInvestment(
).toNumber(); weightedInvestmentsWithCurrencyEffect[index]
).toNumber()
performancePercentagesByDate[historicalDataItem.date] = { });
dividendInPercentageWithCurrencyEffect:
averageInvestmentValueWithCurrencyEffect > 0
? historicalDataItem.dividendInBaseCurrency /
averageInvestmentValueWithCurrencyEffect
: 0,
netPerformanceInPercentage:
averageInvestmentValue > 0
? historicalDataItem.netPerformance / averageInvestmentValue
: 0,
netPerformanceInPercentageWithCurrencyEffect:
averageInvestmentValueWithCurrencyEffect > 0
? historicalDataItem.netPerformanceWithCurrencyEffect /
averageInvestmentValueWithCurrencyEffect
: 0
};
} }
return performancePercentagesByDate; return performancePercentagesByDate;
@ -307,6 +150,31 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
} & AssetProfileIdentifier): HoldingPerformance { } & AssetProfileIdentifier): HoldingPerformance {
const { holdingPerformance, valuation } =
this.getMethodIndependentHoldingPerformance({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
});
if (!valuation) {
return holdingPerformance;
}
const {
currentValuesWithCurrencyEffect,
dateRanges,
daysInMarket,
indexOfEndActivity,
indexOfStartActivity,
investmentValuesAccumulatedWithCurrencyEffect,
items
} = valuation;
const averageInvestmentValues: { [date: string]: Big } = {}; const averageInvestmentValues: { [date: string]: Big } = {};
const averageInvestmentValuesWithCurrencyEffect: { const averageInvestmentValuesWithCurrencyEffect: {
@ -318,107 +186,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDate: Big; let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
let activities: PortfolioCalculatorActivityItem[] =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH';
if (activities.length <= 0) {
return this.getEmptyHoldingPerformance();
}
// The dividends, the interest and the liabilities are derived from the
// activities only. Accumulate them upfront so that they survive the bail
// out for symbols without a market price below.
const {
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency
} = this.getTotalsFromActivities({ activities, exchangeRates });
const dateOfFirstActivity = parseDate(activities[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[assetProfileIdentifier];
const unitPriceAtEndDate = this.getUnitPriceAtEndDate({
activities,
dataSource,
isCash,
marketPriceAtEndDate:
marketSymbolMap[endDateString]?.[assetProfileIdentifier]
});
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start))
) {
// A missing market price can only affect the quantity which is held. The
// dividends, the interest and the liabilities do not hold any quantity
// and are therefore not in error.
const hasActivitiesWithQuantity = activities.some(({ type }) => {
return ['BUY', 'SELL'].includes(type);
});
return {
...this.getEmptyHoldingPerformance(),
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency,
hasErrors: hasActivitiesWithQuantity
};
}
activities = this.getActivitiesWithMarketPrices({
activities,
chartDates,
endDateString,
marketSymbolMap,
startDateString,
unitPriceAtEndDate,
unitPriceAtStartDate,
assetProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
}
});
const {
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
initialValue,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
items,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect
} = this.getHoldingValuation({
activities,
exchangeRates,
unitPriceAtStartDate
});
const indexOfStartActivity = items.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndActivity = items.findIndex(({ itemType }) => {
return itemType === 'end';
});
let sumOfWeightedInvestments = new Big(0); let sumOfWeightedInvestments = new Big(0);
let sumOfWeightedInvestmentsWithCurrencyEffect = new Big(0); let sumOfWeightedInvestmentsWithCurrencyEffect = new Big(0);
let totalInvestmentDays = 0; let totalInvestmentDays = 0;
@ -503,37 +270,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
} }
const {
fees: feesAtStartDate,
feesWithCurrencyEffect: feesAtStartDateWithCurrencyEffect,
grossPerformance: grossPerformanceAtStartDate,
grossPerformanceWithCurrencyEffect:
grossPerformanceAtStartDateWithCurrencyEffect
} = items[indexOfStartActivity];
const {
fees,
feesWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
quantity: totalQuantity
} = items[indexOfEndActivity];
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const averageInvestmentBetweenStartAndEndDate = const averageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0 totalInvestmentDays > 0
? sumOfWeightedInvestments.div(totalInvestmentDays) ? sumOfWeightedInvestments.div(totalInvestmentDays)
@ -544,91 +280,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
? sumOfWeightedInvestmentsWithCurrencyEffect.div(totalInvestmentDays) ? sumOfWeightedInvestmentsWithCurrencyEffect.div(totalInvestmentDays)
: new Big(0); : new Big(0);
const grossPerformancePercentage = const averageInvestmentByDateRange: { [key: DateRange]: Big } = {};
averageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(averageInvestmentBetweenStartAndEndDate)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
averageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(0)
? totalGrossPerformanceWithCurrencyEffect.div(
averageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalQuantity.gt(0)
? fees.minus(feesAtStartDate).div(totalQuantity)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalQuantity.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalQuantity)
: new Big(0);
const netPerformancePercentage = averageInvestmentBetweenStartAndEndDate.gt(
0
)
? totalNetPerformance.div(averageInvestmentBetweenStartAndEndDate)
: new Big(0);
const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity);
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: averageInvestmentBetweenStartAndEndDate.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(
averageInvestmentBetweenStartAndEndDate
)
});
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage:
averageInvestmentBetweenStartAndEndDateWithCurrencyEffect.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(
averageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
});
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
for (const {
dateRange,
endDateString: rangeEndDateString,
startDateString: rangeStartDateString
} of dateRanges) {
const currentValuesAtDateRangeStartWithCurrencyEffect = const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
@ -667,27 +325,61 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
items: averageInvestmentItems items: averageInvestmentItems
}).at(-1); }).at(-1);
const average = weightedInvestment averageInvestmentByDateRange[dateRange] = weightedInvestment
? this.getAverageInvestment(weightedInvestment) ? this.getAverageInvestment(weightedInvestment)
: new Big(0); : new Big(0);
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
} }
const holdingPerformanceWithAverageInvestment =
this.getHoldingPerformanceWithInvestedCapital({
daysInMarket,
holdingPerformance,
investedCapital: averageInvestmentBetweenStartAndEndDate,
investedCapitalByDateRange: averageInvestmentByDateRange,
investedCapitalValues: averageInvestmentValues,
investedCapitalValuesWithCurrencyEffect:
averageInvestmentValuesWithCurrencyEffect,
investedCapitalWithCurrencyEffect:
averageInvestmentBetweenStartAndEndDateWithCurrencyEffect
});
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
const { activities, unitPriceAtEndDate } = valuation;
const {
fees: feesAtStartDate,
feesWithCurrencyEffect: feesAtStartDateWithCurrencyEffect
} = items[indexOfStartActivity];
const {
fees,
feesWithCurrencyEffect,
quantity: totalQuantity
} = items[indexOfEndActivity];
const {
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
totalDividend,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = holdingPerformanceWithAverageInvestment;
const feesPerUnit = totalQuantity.gt(0)
? fees.minus(feesAtStartDate).div(totalQuantity)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalQuantity.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalQuantity)
: new Big(0);
console.log( console.log(
` `
${symbol} ${symbol}
@ -705,10 +397,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
2 2
)} )}
Total dividend: ${totalDividend.toFixed(2)} Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed( Gross performance: ${grossPerformance.toFixed(
2 2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}% )} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( Gross performance with currency effect: ${grossPerformanceWithCurrencyEffect.toFixed(
2 2
)} / ${grossPerformancePercentageWithCurrencyEffect )} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100) .mul(100)
@ -717,7 +409,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2 2
)} )}
Net performance: ${totalNetPerformance.toFixed( Net performance: ${netPerformance.toFixed(
2 2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}% )} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[ Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
@ -726,39 +418,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
); );
} }
return { return holdingPerformanceWithAverageInvestment;
averageInvestmentValues,
averageInvestmentValuesWithCurrencyEffect,
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency,
averageInvestment: averageInvestmentBetweenStartAndEndDate,
averageInvestmentWithCurrencyEffect:
averageInvestmentBetweenStartAndEndDateWithCurrencyEffect,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
};
} }
protected getPerformanceCalculationType() { protected getPerformanceCalculationType() {

159
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-baln-sell-and-buy-on-same-day.spec.ts

@ -83,61 +83,61 @@ describe('PortfolioCalculator (ROI)', () => {
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW sell and buy on the same day', async () => { // The second holding is BALN.SW of another data source, so that both
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); // holdings have the same market prices
const activities: Activity[] = [
// The second holding is BALN.SW of another data source, so that both {
// holdings have the same market prices ...activityDummyData,
const activities: Activity[] = [ assetProfile: {
{ ...assetProfileDummyData,
...activityDummyData, currency: 'CHF',
assetProfile: { dataSource: 'YAHOO',
...assetProfileDummyData, name: 'Bâloise Holding AG',
currency: 'CHF', symbol: 'BALN.SW'
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
}, },
{ date: new Date('2021-11-22'),
...activityDummyData, feeInAssetProfileCurrency: 1.55,
assetProfile: { feeInBaseCurrency: 1.55,
...assetProfileDummyData, quantity: 2,
currency: 'CHF', type: 'BUY',
dataSource: 'YAHOO', unitPriceInAssetProfileCurrency: 142.9
name: 'Bâloise Holding AG', },
symbol: 'BALN.SW' {
}, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.65, ...assetProfileDummyData,
feeInBaseCurrency: 1.65, currency: 'CHF',
quantity: 2, dataSource: 'YAHOO',
type: 'SELL', name: 'Bâloise Holding AG',
unitPriceInAssetProfileCurrency: 136.6 symbol: 'BALN.SW'
}, },
{ date: new Date('2021-11-30'),
...activityDummyData, feeInAssetProfileCurrency: 1.65,
assetProfile: { feeInBaseCurrency: 1.65,
...assetProfileDummyData, quantity: 2,
currency: 'CHF', type: 'SELL',
dataSource: 'EOD_HISTORICAL_DATA', unitPriceInAssetProfileCurrency: 136.6
name: 'Bâloise Holding AG', },
symbol: 'BALN.SW' {
}, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0, currency: 'CHF',
quantity: 2, dataSource: 'EOD_HISTORICAL_DATA',
type: 'BUY', name: 'Bâloise Holding AG',
unitPriceInAssetProfileCurrency: 136.6 symbol: 'BALN.SW'
} },
]; date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}
];
it.only('with BALN.SW sell and buy on the same day', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
@ -233,5 +233,58 @@ describe('PortfolioCalculator (ROI)', () => {
} }
}); });
}); });
it.only('with BALN.SW sell and buy on the same day and a dividend', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [
...activities,
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'EOD_HISTORICAL_DATA',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-12-17'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 1.43
}
],
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
/**
* The dividend yield of the holding uses the invested capital of the
* holding: (1 + 2.86 CHF / 273.2 CHF) ^ (365 / 18 days) - 1 ≈ 23.51 %
*/
expect(portfolioSnapshot.positions[0]).toMatchObject({
averageInvestment: new Big('273.2'),
dataSource: 'EOD_HISTORICAL_DATA',
dividendInBaseCurrency: new Big('2.86'),
dividendYieldPercent: new Big('0.2351283862005056')
});
/**
* The dividend yield of the portfolio uses the invested capital of the
* portfolio, 285.8 CHF, and not the sum of the holdings, 559 CHF:
* (1 + 2.86 CHF / 285.8 CHF) ^ (365 / 26 days) - 1 ≈ 15.00 %
*/
expect(portfolioSnapshot).toMatchObject({
dividendYieldPercent: new Big('0.1500260608512471'),
dividendYieldPercentWithCurrencyEffect: new Big('0.1500260608512471')
});
});
}); });
}); });

614
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -1,18 +1,9 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface'; import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.interface';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface'; import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface'; import { InvestedCapital } from '@ghostfolio/api/app/portfolio/interfaces/invested-capital.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.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 { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HistoricalDataItem HistoricalDataItem
@ -22,13 +13,7 @@ import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import { mapValues, sortedLastIndex } from 'lodash-es';
differenceInDays,
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
/** /**
* Return on Investment (ROI): the gain divided by the invested capital, without * Return on Investment (ROI): the gain divided by the invested capital, without
@ -51,134 +36,27 @@ import {
* another on different dates counts two times. * another on different dates counts two times.
*/ */
export class RoiPortfolioCalculator extends PortfolioCalculator { export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance( protected calculateOverallPerformance({
positions: PortfolioCalculatorHolding[] accumulatedValuesByDate,
): PortfolioSnapshot { positions
let currentValueInBaseCurrency = new Big(0); }: {
let hasErrors = false; accumulatedValuesByDate: { [date: string]: AccumulatedValues };
let totalFeesWithCurrencyEffect = new Big(0); positions: PortfolioCalculatorHolding[];
const totalInterestWithCurrencyEffect = new Big(0); }): PortfolioSnapshot {
let totalInvestedCapital = new Big(0); // The invested capital of the portfolio at the last date
let totalInvestedCapitalWithCurrencyEffect = new Big(0); const { investedCapital, investedCapitalWithCurrencyEffect } =
let totalInvestment = new Big(0); Object.values(
let totalInvestmentWithCurrencyEffect = new Big(0); this.getInvestedCapitalByDate({ accumulatedValuesByDate })
).at(-1) ?? {
for (const currentPosition of positions) { investedCapital: new Big(0),
if (currentPosition.valueInBaseCurrency) { investedCapitalWithCurrencyEffect: new Big(0)
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( };
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (!currentPosition.includeInPerformance) {
continue;
}
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (
!currentPosition.grossPerformance &&
!currentPosition.quantity.eq(0)
) {
hasErrors = true;
}
if (currentPosition.averageInvestment) {
totalInvestedCapital = totalInvestedCapital.plus(
currentPosition.averageInvestment
);
totalInvestedCapitalWithCurrencyEffect =
totalInvestedCapitalWithCurrencyEffect.plus(
currentPosition.averageInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
);
hasErrors = true;
}
}
const dateOfFirstActivity = this.getStartDate();
const daysInMarket = dateOfFirstActivity
? differenceInDays(new Date(), dateOfFirstActivity)
: 0;
const totalDividendInBaseCurrency =
this.getDividendInBaseCurrencyOfHoldings(positions);
// A holding without a market price, and a holding which is excluded from
// the performance, gives a dividend but no invested capital. Such a
// holding makes the dividend yield too high. Therefore the dividend yield
// stays 0 in this case.
const hasDividendWithoutInvestedCapital = positions.some(
({ averageInvestment, dividendInBaseCurrency, includeInPerformance }) => {
return (
!dividendInBaseCurrency.eq(0) &&
(!includeInPerformance || averageInvestment.eq(0))
);
}
);
const dividendYieldPercent = getAnnualizedPerformancePercent({ return this.getOverallPerformance({
daysInMarket, investedCapital,
netPerformancePercentage: investedCapitalWithCurrencyEffect,
hasDividendWithoutInvestedCapital || totalInvestedCapital.eq(0) positions
? new Big(0)
: totalDividendInBaseCurrency.div(totalInvestedCapital)
}); });
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage:
hasDividendWithoutInvestedCapital ||
totalInvestedCapitalWithCurrencyEffect.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(
totalInvestedCapitalWithCurrencyEffect
)
});
return {
currentValueInBaseCurrency,
dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalCashInBaseCurrency: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0)
};
} }
protected calculatePerformancePercentages({ protected calculatePerformancePercentages({
@ -186,74 +64,16 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
}: { }: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues }; accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}): { [date: string]: PerformancePercentages } { }): { [date: string]: PerformancePercentages } {
let investedCapital = new Big(0); const investedCapitalByDate = this.getInvestedCapitalByDate({
let investedCapitalWithCurrencyEffect = new Big(0); accumulatedValuesByDate
let previousTotalInvestmentValue = new Big(0); });
let previousTotalInvestmentValueWithCurrencyEffect = new Big(0);
const performancePercentagesByDate: {
[date: string]: PerformancePercentages;
} = {};
// The dates are in chronological order
for (const [
date,
{
totalDividendValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect
}
] of Object.entries(accumulatedValuesByDate)) {
// A positive net investment change of the portfolio increases the
// invested capital. A negative change does not reduce it, so that a sold
// holding keeps its share of the gain.
const investmentChange = totalInvestmentValue.minus(
previousTotalInvestmentValue
);
if (investmentChange.gt(0)) {
investedCapital = investedCapital.plus(investmentChange);
}
const investmentChangeWithCurrencyEffect =
totalInvestmentValueWithCurrencyEffect.minus(
previousTotalInvestmentValueWithCurrencyEffect
);
if (investmentChangeWithCurrencyEffect.gt(0)) {
investedCapitalWithCurrencyEffect =
investedCapitalWithCurrencyEffect.plus(
investmentChangeWithCurrencyEffect
);
}
previousTotalInvestmentValue = totalInvestmentValue;
previousTotalInvestmentValueWithCurrencyEffect =
totalInvestmentValueWithCurrencyEffect;
performancePercentagesByDate[date] = {
dividendInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalDividendValueWithCurrencyEffect
.div(investedCapitalWithCurrencyEffect)
.toNumber(),
netPerformanceInPercentage: investedCapital.eq(0)
? 0
: totalNetPerformanceValue.div(investedCapital).toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(investedCapitalWithCurrencyEffect)
.toNumber()
};
}
return performancePercentagesByDate; return mapValues(accumulatedValuesByDate, (accumulatedValues, date) => {
return this.getPerformancePercentagesOfAccumulatedValues({
accumulatedValues,
...investedCapitalByDate[date]
});
});
} }
protected calculatePerformancePercentagesForDateRange({ protected calculatePerformancePercentagesForDateRange({
@ -297,22 +117,12 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
} }
} }
performancePercentagesByDate[historicalDataItem.date] = { performancePercentagesByDate[historicalDataItem.date] =
dividendInPercentageWithCurrencyEffect: this.getPerformancePercentagesOfHistoricalDataItem({
investedCapitalWithCurrencyEffect > 0 historicalDataItem,
? historicalDataItem.dividendInBaseCurrency / investedCapital,
investedCapitalWithCurrencyEffect investedCapitalWithCurrencyEffect
: 0, });
netPerformanceInPercentage:
investedCapital > 0
? historicalDataItem.netPerformance / investedCapital
: 0,
netPerformanceInPercentageWithCurrencyEffect:
investedCapitalWithCurrencyEffect > 0
? historicalDataItem.netPerformanceWithCurrencyEffect /
investedCapitalWithCurrencyEffect
: 0
};
} }
return performancePercentagesByDate; return performancePercentagesByDate;
@ -335,106 +145,29 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
} & AssetProfileIdentifier): HoldingPerformance { } & AssetProfileIdentifier): HoldingPerformance {
const assetProfileIdentifier = getAssetProfileIdentifier({ const { holdingPerformance, valuation } =
dataSource, this.getMethodIndependentHoldingPerformance({
symbol chartDates,
}); dataSource,
end,
let activities: PortfolioCalculatorActivityItem[] = exchangeRates,
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []; marketSymbolMap,
start,
const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH'; symbol
if (activities.length <= 0) {
return this.getEmptyHoldingPerformance();
}
// The dividends, the interest and the liabilities are derived from the
// activities only. Accumulate them upfront so that they survive the bail
// out for symbols without a market price below.
const {
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency
} = this.getTotalsFromActivities({ activities, exchangeRates });
const dateOfFirstActivity = parseDate(activities[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[assetProfileIdentifier];
const unitPriceAtEndDate = this.getUnitPriceAtEndDate({
activities,
dataSource,
isCash,
marketPriceAtEndDate:
marketSymbolMap[endDateString]?.[assetProfileIdentifier]
});
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstActivity, start))
) {
// A missing market price can only affect the quantity which is held. The
// dividends, the interest and the liabilities do not hold any quantity
// and are therefore not in error.
const hasActivitiesWithQuantity = activities.some(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}); });
return { if (!valuation) {
...this.getEmptyHoldingPerformance(), return holdingPerformance;
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency,
hasErrors: hasActivitiesWithQuantity
};
} }
activities = this.getActivitiesWithMarketPrices({
activities,
chartDates,
endDateString,
marketSymbolMap,
startDateString,
unitPriceAtEndDate,
unitPriceAtStartDate,
assetProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
}
});
const { const {
currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect, dateRanges,
initialValue, daysInMarket,
investmentValuesAccumulated, indexOfEndActivity,
investmentValuesAccumulatedWithCurrencyEffect, indexOfStartActivity,
investmentValuesWithCurrencyEffect, items
items, } = valuation;
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect
} = this.getHoldingValuation({
activities,
exchangeRates,
unitPriceAtStartDate
});
const indexOfStartActivity = items.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndActivity = items.findIndex(({ itemType }) => {
return itemType === 'end';
});
let investedCapital = new Big(0); let investedCapital = new Big(0);
let investedCapitalWithCurrencyEffect = new Big(0); let investedCapitalWithCurrencyEffect = new Big(0);
@ -455,174 +188,117 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
} }
} }
const { // The sum of the buy amounts up to each item. The items are in
fees: feesAtStartDate, // chronological order.
grossPerformance: grossPerformanceAtStartDate, const buyAmountsAccumulatedWithCurrencyEffect: Big[] = [];
grossPerformanceWithCurrencyEffect: let buyAmountWithCurrencyEffect = new Big(0);
grossPerformanceAtStartDateWithCurrencyEffect
} = items[indexOfStartActivity];
const { for (const { transactionInvestmentWithCurrencyEffect } of items) {
fees, if (transactionInvestmentWithCurrencyEffect.gt(0)) {
grossPerformance, buyAmountWithCurrencyEffect = buyAmountWithCurrencyEffect.plus(
grossPerformanceWithCurrencyEffect, transactionInvestmentWithCurrencyEffect
investment: totalInvestment, );
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, }
quantity: totalQuantity
} = items[indexOfEndActivity];
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = totalGrossPerformance.minus( buyAmountsAccumulatedWithCurrencyEffect.push(buyAmountWithCurrencyEffect);
fees.minus(feesAtStartDate) }
);
const grossPerformancePercentage = investedCapital.gt(0) const itemDates = items.map(({ date }) => {
? totalGrossPerformance.div(investedCapital) return date;
: new Big(0); });
const grossPerformancePercentageWithCurrencyEffect = const investedCapitalByDateRange: { [key: DateRange]: Big } = {};
investedCapitalWithCurrencyEffect.gt(0)
? totalGrossPerformanceWithCurrencyEffect.div(
investedCapitalWithCurrencyEffect
)
: new Big(0);
const netPerformancePercentage = investedCapital.gt(0) for (const { dateRange, endDateString, startDateString } of dateRanges) {
? totalNetPerformance.div(investedCapital) const buyAmountUntilEndDateWithCurrencyEffect =
: new Big(0); buyAmountsAccumulatedWithCurrencyEffect[
sortedLastIndex(itemDates, endDateString) - 1
] ?? new Big(0);
const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); const buyAmountUntilStartDateWithCurrencyEffect =
buyAmountsAccumulatedWithCurrencyEffect[
sortedLastIndex(itemDates, startDateString) - 1
] ?? new Big(0);
const dividendYieldPercent = getAnnualizedPerformancePercent({ // The invested capital of the date range is the value at the start date
// plus the buy amounts during the range
investedCapitalByDateRange[dateRange] = (
currentValuesWithCurrencyEffect[startDateString] ?? new Big(0)
).plus(
buyAmountUntilEndDateWithCurrencyEffect.minus(
buyAmountUntilStartDateWithCurrencyEffect
)
);
}
return this.getHoldingPerformanceWithInvestedCapital({
daysInMarket, daysInMarket,
netPerformancePercentage: investedCapital.eq(0) holdingPerformance,
? new Big(0) investedCapital,
: totalDividendInBaseCurrency.div(investedCapital) investedCapitalByDateRange,
investedCapitalWithCurrencyEffect
}); });
}
const dividendYieldPercentWithCurrencyEffect = protected getPerformanceCalculationType() {
getAnnualizedPerformancePercent({ return PerformanceCalculationType.ROI;
daysInMarket, }
netPerformancePercentage: investedCapitalWithCurrencyEffect.eq(0)
? new Big(0)
: totalDividendInBaseCurrency.div(investedCapitalWithCurrencyEffect)
});
const netPerformancePercentageWithCurrencyEffectMap: { /**
[key: DateRange]: Big; * Returns the invested capital of the portfolio per date: the sum of the
} = {}; * positive net investment changes up to the date
*/
private getInvestedCapitalByDate({
accumulatedValuesByDate
}: {
accumulatedValuesByDate: { [date: string]: AccumulatedValues };
}) {
let investedCapital = new Big(0);
let investedCapitalWithCurrencyEffect = new Big(0);
let previousTotalInvestmentValue = new Big(0);
let previousTotalInvestmentValueWithCurrencyEffect = new Big(0);
const netPerformanceWithCurrencyEffectMap: { const investedCapitalByDate: { [date: string]: InvestedCapital } = {};
[key: DateRange]: Big;
} = {}; // The dates are in chronological order
for (const [
date,
{ totalInvestmentValue, totalInvestmentValueWithCurrencyEffect }
] of Object.entries(accumulatedValuesByDate)) {
// A positive net investment change of the portfolio increases the
// invested capital. A negative change does not reduce it, so that a sold
// holding keeps its share of the gain.
const investmentChange = totalInvestmentValue.minus(
previousTotalInvestmentValue
);
for (const dateRange of [ if (investmentChange.gt(0)) {
'1d', investedCapital = investedCapital.plus(investmentChange);
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
} }
const rangeEndDateString = format(endDate, DATE_FORMAT); const investmentChangeWithCurrencyEffect =
const rangeStartDateString = format(startDate, DATE_FORMAT); totalInvestmentValueWithCurrencyEffect.minus(
previousTotalInvestmentValueWithCurrencyEffect
);
// The invested capital of the date range is the value at the start date if (investmentChangeWithCurrencyEffect.gt(0)) {
// plus the buy amounts during the range investedCapitalWithCurrencyEffect =
let investedCapitalOfDateRangeWithCurrencyEffect = investedCapitalWithCurrencyEffect.plus(
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); investmentChangeWithCurrencyEffect
);
for (const item of items) {
if (
item.date > rangeStartDateString &&
item.date <= rangeEndDateString &&
item.transactionInvestmentWithCurrencyEffect.gt(0)
) {
investedCapitalOfDateRangeWithCurrencyEffect =
investedCapitalOfDateRangeWithCurrencyEffect.plus(
item.transactionInvestmentWithCurrencyEffect
);
}
} }
netPerformanceWithCurrencyEffectMap[dateRange] = previousTotalInvestmentValue = totalInvestmentValue;
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] =
investedCapitalOfDateRangeWithCurrencyEffect.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(
investedCapitalOfDateRangeWithCurrencyEffect
)
: new Big(0);
}
return { previousTotalInvestmentValueWithCurrencyEffect =
currentValues, totalInvestmentValueWithCurrencyEffect;
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
dividendYieldPercent,
dividendYieldPercentWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency,
averageInvestment: investedCapital,
averageInvestmentValues: {},
averageInvestmentValuesWithCurrencyEffect: {},
averageInvestmentWithCurrencyEffect: investedCapitalWithCurrencyEffect,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
};
}
protected getPerformanceCalculationType() { investedCapitalByDate[date] = {
return PerformanceCalculationType.ROI; investedCapital,
investedCapitalWithCurrencyEffect
};
}
return investedCapitalByDate;
} }
} }

7
apps/api/src/app/portfolio/interfaces/date-range-interval.interface.ts

@ -0,0 +1,7 @@
import { DateRange } from '@ghostfolio/common/types';
export interface DateRangeInterval {
dateRange: DateRange;
endDateString: string;
startDateString: string;
}

14
apps/api/src/app/portfolio/interfaces/holding-performance-valuation.interface.ts

@ -0,0 +1,14 @@
import { Big } from 'big.js';
import { DateRangeInterval } from './date-range-interval.interface';
import { HoldingValuation } from './holding-valuation.interface';
import { PortfolioCalculatorActivityItem } from './portfolio-calculator-activity-item.interface';
export interface HoldingPerformanceValuation extends HoldingValuation {
activities: PortfolioCalculatorActivityItem[];
dateRanges: DateRangeInterval[];
daysInMarket: number;
indexOfEndActivity: number;
indexOfStartActivity: number;
unitPriceAtEndDate: Big;
}

6
apps/api/src/app/portfolio/interfaces/invested-capital.interface.ts

@ -0,0 +1,6 @@
import { Big } from 'big.js';
export interface InvestedCapital {
investedCapital: Big;
investedCapitalWithCurrencyEffect: Big;
}

7
apps/api/src/app/portfolio/interfaces/method-independent-holding-performance.interface.ts

@ -0,0 +1,7 @@
import { HoldingPerformanceValuation } from './holding-performance-valuation.interface';
import { HoldingPerformance } from './holding-performance.interface';
export interface MethodIndependentHoldingPerformance {
holdingPerformance: HoldingPerformance;
valuation?: HoldingPerformanceValuation;
}
Loading…
Cancel
Save