Browse Source

Task/move method independent helpers of portfolio calculator to base class (#7914)

Move method-independent helpers of portfolio calculator from ROAI calculator into base class
pull/7916/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
3eec62bca4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  2. 246
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 244
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  4. 2
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  5. 2
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

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

@ -11,7 +11,7 @@ export class MwrPortfolioCalculator extends PortfolioCalculator {
}
protected getHoldingPerformance({}: {
chartDateMap: { [date: string]: boolean };
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {

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

@ -2,6 +2,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { HoldingPerformance } from '@ghostfolio/api/app/portfolio/interfaces/holding-performance.interface';
import { PortfolioCalculatorHolding } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-holding.interface';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
@ -43,10 +44,11 @@ import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
addMilliseconds,
differenceInDays,
eachDayOfInterval,
eachYearOfInterval,
@ -425,7 +427,7 @@ export abstract class PortfolioCalculator {
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency
} = this.getHoldingPerformance({
chartDateMap,
chartDates,
marketSymbolMap,
dataSource: item.dataSource,
end: this.endDate,
@ -728,6 +730,37 @@ export abstract class PortfolioCalculator {
);
}
protected getEmptyHoldingPerformance(): HoldingPerformance {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0)
};
}
public async getFeesInBaseCurrency() {
await this.snapshotPromise;
@ -735,7 +768,7 @@ export abstract class PortfolioCalculator {
}
protected abstract getHoldingPerformance({
chartDateMap,
chartDates,
dataSource,
end,
exchangeRates,
@ -743,7 +776,7 @@ export abstract class PortfolioCalculator {
start,
symbol
}: {
chartDateMap: { [date: string]: boolean };
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
@ -804,6 +837,137 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalLiabilitiesWithCurrencyEffect;
}
protected getOrdersWithMarketPrices({
assetProfile,
chartDates,
endDateString,
marketSymbolMap,
orders,
startDateString,
unitPriceAtEndDate,
unitPriceAtStartDate
}: {
assetProfile: PortfolioOrderItem['assetProfile'];
chartDates: string[];
endDateString: string;
marketSymbolMap: {
[date: string]: { [assetProfileIdentifier: string]: Big };
};
orders: PortfolioOrderItem[];
startDateString: string;
unitPriceAtEndDate: Big;
unitPriceAtStartDate: Big;
}): PortfolioOrderItem[] {
if (orders.length <= 0) {
return [];
}
const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile);
const dateStringOfFirstActivity = orders[0].date;
// Copy the items as they are enriched below. A shallow copy is sufficient
// because only top-level properties are written.
const ordersWithMarketPrices = orders.map((order) => {
return { ...order };
});
// Add a synthetic order at the start and the end date
ordersWithMarketPrices.push({
assetProfile,
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
ordersWithMarketPrices.push({
assetProfile,
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
// Fall back to the unit price of the most recent BUY / SELL activity for
// the chart dates before the first known market price of the symbol
let lastActivityUnitPrice: Big | undefined;
let lastMarketPrice: Big | undefined;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of ordersWithMarketPrices) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
for (const dateString of chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
const ordersOfDate = ordersByDate[dateString];
if (!lastMarketPrice && ordersOfDate?.length > 0) {
for (const { itemType, type, unitPrice } of ordersOfDate) {
if (!itemType && ['BUY', 'SELL'].includes(type)) {
lastActivityUnitPrice = unitPrice;
}
}
}
const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier];
const unitPrice =
marketPrice ??
lastMarketPrice ??
lastActivityUnitPrice ??
unitPriceAtEndDate;
if (ordersOfDate?.length > 0) {
for (const order of ordersOfDate) {
order.unitPriceFromMarketData = unitPrice;
}
} else if (dateString >= dateStringOfFirstActivity) {
ordersWithMarketPrices.push({
assetProfile,
unitPrice,
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
type: 'BUY',
unitPriceFromMarketData: unitPrice
});
}
if (marketPrice) {
lastMarketPrice = marketPrice;
}
}
// Sort orders so that the start and end placeholder order are at the correct
// position
return sortBy(ordersWithMarketPrices, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
}
public async getPerformance({ end, start }) {
await this.snapshotPromise;
@ -939,10 +1103,84 @@ export abstract class PortfolioCalculator {
return min(dates);
}
protected getTotalsFromActivities({
exchangeRates,
orders
}: {
exchangeRates: { [dateString: string]: number };
orders: PortfolioOrder[];
}) {
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
for (const order of orders) {
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
}
return {
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency
};
}
public getTransactionPoints() {
return this.transactionPoints;
}
protected getUnitPriceAtEndDate({
dataSource,
isCash,
marketPriceAtEndDate,
orders
}: {
dataSource: DataSource;
isCash: boolean;
marketPriceAtEndDate: Big;
orders: PortfolioOrder[];
}): Big {
const latestActivity = orders.at(-1);
if (
dataSource === 'MANUAL' &&
['BUY', 'SELL'].includes(latestActivity?.type) &&
latestActivity?.unitPrice &&
!marketPriceAtEndDate
) {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
return latestActivity.unitPrice;
} else if (isCash) {
return new Big(1);
}
return marketPriceAtEndDate;
}
private getChartDateMap({
endDate,
startDate,

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

@ -16,18 +16,14 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Big } from 'big.js';
import {
addMilliseconds,
differenceInDays,
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
import { sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: PortfolioCalculatorHolding[]
): PortfolioSnapshot {
@ -126,7 +122,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}
protected getHoldingPerformance({
chartDateMap,
chartDates,
dataSource,
end,
exchangeRates,
@ -134,7 +130,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
start,
symbol
}: {
chartDateMap: { [date: string]: boolean };
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
@ -173,14 +169,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
[date: string]: Big;
} = {};
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantity = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let valueAtStartDate: Big;
@ -191,77 +183,26 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
symbol
});
// Copy the items as they are enriched below. A shallow copy is sufficient
// because only top-level properties are written.
let orders: PortfolioOrderItem[] = (
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []
).map((activity) => {
return { ...activity };
});
let orders: PortfolioOrderItem[] =
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [];
const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(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.
for (const order of orders) {
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
}
const {
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency
} = this.getTotalsFromActivities({ exchangeRates, orders });
const dateStringOfFirstActivity = orders[0].date;
const dateOfFirstActivity = parseDate(dateStringOfFirstActivity);
const dateOfFirstActivity = parseDate(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
@ -269,23 +210,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[assetProfileIdentifier];
let unitPriceAtEndDate =
marketSymbolMap[endDateString]?.[assetProfileIdentifier];
const latestActivity = orders.at(-1);
if (
dataSource === 'MANUAL' &&
['BUY', 'SELL'].includes(latestActivity?.type) &&
latestActivity?.unitPrice &&
!unitPriceAtEndDate
) {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
}
const unitPriceAtEndDate = this.getUnitPriceAtEndDate({
dataSource,
isCash,
orders,
marketPriceAtEndDate:
marketSymbolMap[endDateString]?.[assetProfileIdentifier]
});
if (
!unitPriceAtEndDate ||
@ -299,139 +230,28 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
});
return {
...this.getEmptyHoldingPerformance(),
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalLiabilitiesInBaseCurrency,
currentValues: {},
currentValuesWithCurrencyEffect: {},
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: hasActivitiesWithQuantity,
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0)
hasErrors: hasActivitiesWithQuantity
};
}
const assetProfile: PortfolioOrderItem['assetProfile'] = {
orders = this.getOrdersWithMarketPrices({
chartDates,
endDateString,
marketSymbolMap,
orders,
startDateString,
unitPriceAtEndDate,
unitPriceAtStartDate,
assetProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
};
// Add a synthetic order at the start and the end date
orders.push({
assetProfile,
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
assetProfile,
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
// Fall back to the unit price of the most recent BUY / SELL activity for
// the chart dates before the first known market price of the symbol
let lastActivityUnitPrice: Big | undefined;
let lastMarketPrice: Big | undefined;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
const ordersOfDate = ordersByDate[dateString];
if (!lastMarketPrice && ordersOfDate?.length > 0) {
for (const { itemType, type, unitPrice } of ordersOfDate) {
if (!itemType && ['BUY', 'SELL'].includes(type)) {
lastActivityUnitPrice = unitPrice;
}
}
}
const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier];
const unitPrice =
marketPrice ??
lastMarketPrice ??
lastActivityUnitPrice ??
unitPriceAtEndDate;
if (ordersOfDate?.length > 0) {
for (const order of ordersOfDate) {
order.unitPriceFromMarketData = unitPrice;
}
} else if (dateString >= dateStringOfFirstActivity) {
orders.push({
assetProfile,
unitPrice,
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
type: 'BUY',
unitPriceFromMarketData: unitPrice
});
}
if (marketPrice) {
lastMarketPrice = marketPrice;
}
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
@ -901,8 +721,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
for (let i = chartDates.length - 1; i >= 0; i -= 1) {
const date = chartDates[i];
if (date > rangeEndDateString) {
continue;

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

@ -11,7 +11,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
}
protected getHoldingPerformance({}: {
chartDateMap: { [date: string]: boolean };
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -11,7 +11,7 @@ export class TwrPortfolioCalculator extends PortfolioCalculator {
}
protected getHoldingPerformance({}: {
chartDateMap: { [date: string]: boolean };
chartDates: string[];
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {

Loading…
Cancel
Save