Browse Source

Extract method independent holding valuation helpers into base portfolio calculator

pull/8188/head
Pedro Moreira 1 week ago
parent
commit
c80b1f6b36
  1. 208
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  2. 186
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  3. 22
      apps/api/src/app/portfolio/types/holding-valuation-of-period.type.ts

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

@ -10,6 +10,7 @@ import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/i
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 { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { HoldingValuationOfPeriod } from '@ghostfolio/api/app/portfolio/types/holding-valuation-of-period.type';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
@ -47,7 +48,7 @@ import {
PortfolioSnapshot,
PortfolioSnapshotHolding
} 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 { Logger } from '@nestjs/common';
@ -66,6 +67,7 @@ import {
isBefore,
isFuture,
isPast,
isThisYear,
isWithinInterval,
min,
startOfDay,
@ -891,6 +893,29 @@ export abstract class PortfolioCalculator {
return this.dataProviderInfos;
}
/**
* Returns the date ranges of the performance of a holding: the fixed ranges
* and each past calendar year between the start and the end date
*/
protected getDateRanges({ end, start }: { end: Date; start: Date }) {
return [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[];
}
protected getDividendInBaseCurrencyOfHoldings(
holdings: PortfolioSnapshotHolding[]
) {
@ -1354,6 +1379,129 @@ export abstract class PortfolioCalculator {
};
}
/**
* Returns the valuation of a holding between the start and the end date. If
* the holding has no activities or a market price is missing, it returns
* the holding performance which only contains the totals of the activities.
*/
protected getHoldingValuationOfPeriod({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): HoldingValuationOfPeriod {
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
let activities: PortfolioCalculatorActivityItem[] =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH';
if (activities.length <= 0) {
return {
hasMarketPrices: false,
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 {
hasMarketPrices: false,
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
});
return {
activities,
dateOfFirstActivity,
holdingValuation,
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency,
unitPriceAtEndDate,
hasMarketPrices: true
};
}
public async getInterestInBaseCurrency() {
await this.snapshotPromise;
@ -1561,6 +1709,64 @@ export abstract class PortfolioCalculator {
};
}
/**
* Returns the totals of the positions which do not depend on the
* performance calculation type
*/
protected getTotalsOfPositions(positions: PortfolioCalculatorHolding[]) {
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 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;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
totalFeesWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect
};
}
protected getUnitPriceAtEndDate({
activities,
dataSource,

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

@ -2,7 +2,6 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po
import { AccumulatedValues } from '@ghostfolio/api/app/portfolio/interfaces/accumulated-values.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 { 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 { WeightedInvestment } from '@ghostfolio/api/app/portfolio/interfaces/weighted-investment.interface';
import { PerformancePercentages } from '@ghostfolio/api/app/portfolio/types/performance-percentages.type';
@ -10,11 +9,7 @@ import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HistoricalDataItem
@ -24,65 +19,32 @@ import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import {
differenceInDays,
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
import { differenceInDays, format, isBefore } from 'date-fns';
import { sortedIndex } from 'lodash-es';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
positions: PortfolioCalculatorHolding[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let hasErrors = false;
const totalsOfPositions = this.getTotalsOfPositions(positions);
const {
currentValueInBaseCurrency,
totalFeesWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = totalsOfPositions;
let { hasErrors } = totalsOfPositions;
let totalAverageInvestment = new Big(0);
let totalAverageInvestmentWithCurrencyEffect = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
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
@ -318,98 +280,41 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
const assetProfileIdentifier = getAssetProfileIdentifier({
const holdingValuationOfPeriod = this.getHoldingValuationOfPeriod({
chartDates,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
});
let activities: PortfolioCalculatorActivityItem[] =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const isCash = activities[0]?.assetProfile?.assetSubClass === 'CASH';
if (activities.length <= 0) {
return this.getEmptyHoldingPerformance();
if (holdingValuationOfPeriod.hasMarketPrices === false) {
return holdingValuationOfPeriod.holdingPerformance;
}
// 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 {
activities,
dateOfFirstActivity,
holdingValuation: {
currentValues,
currentValuesWithCurrencyEffect,
dividendValuesWithCurrencyEffect,
initialValue,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
items,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect
},
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
});
totalLiabilitiesInBaseCurrency,
unitPriceAtEndDate
} = holdingValuationOfPeriod;
const indexOfStartActivity = items.findIndex(({ itemType }) => {
return itemType === 'start';
@ -602,22 +507,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
[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[]) {
for (const dateRange of this.getDateRanges({ end, start })) {
const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;

22
apps/api/src/app/portfolio/types/holding-valuation-of-period.type.ts

@ -0,0 +1,22 @@
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { HoldingValuation } from '@ghostfolio/api/app/portfolio/interfaces/holding-valuation.interface';
import { PortfolioCalculatorActivityItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-activity-item.interface';
import { Big } from 'big.js';
export type HoldingValuationOfPeriod =
| {
hasMarketPrices: false;
holdingPerformance: HoldingPerformance;
}
| {
activities: PortfolioCalculatorActivityItem[];
dateOfFirstActivity: Date;
hasMarketPrices: true;
holdingValuation: HoldingValuation;
totalDividend: Big;
totalDividendInBaseCurrency: Big;
totalInterestInBaseCurrency: Big;
totalLiabilitiesInBaseCurrency: Big;
unitPriceAtEndDate: Big;
};
Loading…
Cancel
Save