Browse Source

Readded TimeWeighted Performance

pull/5027/head
Dan 1 year ago
parent
commit
97ba28fe1c
  1. 298
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  2. 17
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  3. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  4. 54
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 1
      libs/common/src/lib/interfaces/historical-data-item.interface.ts

298
apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts

@ -0,0 +1,298 @@
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import {
HistoricalDataItem,
SymbolMetrics,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Big } from 'big.js';
import { addDays, eachDayOfInterval } from 'date-fns';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { TWRPortfolioCalculator } from '../twr/portfolio-calculator';
export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
return super.calculateOverallPerformance(positions);
}
protected getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
return super.getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode,
marketSymbolMap,
start,
step,
symbol
});
}
public override async getChartData({
end = new Date(Date.now()),
start,
step = 1
}: {
end?: Date;
start: Date;
step?: number;
}): Promise<HistoricalDataItem[]> {
const timelineHoldings = this.getHoldings(start, end);
const calculationDates = Object.keys(timelineHoldings)
.filter((date) => {
let parsed = parseDate(date);
parsed >= start && parsed <= end;
})
.sort();
let data: HistoricalDataItem[] = [];
data.push({
date: start.toDateString(),
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
let totalInvestment = Object.keys(
timelineHoldings[start.toDateString()]
).reduce((sum, holding) => {
return sum.plus(
timelineHoldings[start.toDateString()][holding].mul(
this.marketMap[start.toDateString()][holding]
)
);
}, new Big(0));
let previousNetPerformanceInPercentage = new Big(0);
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (let i = 1; i < calculationDates.length; i++) {
const date = calculationDates[i];
const previousDate = calculationDates[i - 1];
const holdings = timelineHoldings[previousDate];
let newTotalInvestment = new Big(0);
let netPerformanceInPercentage = new Big(0);
let netPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (const holding of Object.keys(holdings)) {
({
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
} = await this.handleSingleHolding(
previousDate,
holding,
date,
totalInvestment,
timelineHoldings,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
));
totalInvestment = newTotalInvestment;
}
previousNetPerformanceInPercentage =
previousNetPerformanceInPercentage.mul(
netPerformanceInPercentage.plus(1)
);
previousNetPerformanceInPercentageWithCurrencyEffect =
previousNetPerformanceInPercentageWithCurrencyEffect.mul(
netPerformanceInPercentageWithCurrencyEffect.plus(1)
);
data.push({
date,
netPerformanceInPercentage:
previousNetPerformanceInPercentage.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
previousNetPerformanceInPercentageWithCurrencyEffect.toNumber()
});
}
return data;
}
private async handleSingleHolding(
previousDate: string,
holding: string,
date: string,
totalInvestment,
timelineHoldings: { [date: string]: { [symbol: string]: Big } },
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
) {
const previousPrice = this.marketMap[previousDate][holding];
const currentPrice = this.marketMap[date][holding];
const previousPriceInBaseCurrency =
await this.exchangeRateDataService.toCurrencyAtDate(
previousPrice.toNumber(),
this.getCurrency(holding),
this.currency,
parseDate(previousDate)
);
const portfolioWeight = totalInvestment
? timelineHoldings[previousDate][holding]
.mul(previousPriceInBaseCurrency)
.div(totalInvestment)
: 0;
netPerformanceInPercentage = netPerformanceInPercentage.plus(
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight)
);
const priceInBaseCurrency =
await this.exchangeRateDataService.toCurrencyAtDate(
currentPrice.toNumber(),
this.getCurrency(holding),
this.currency,
parseDate(date)
);
netPerformanceInPercentageWithCurrencyEffect =
netPerformanceInPercentageWithCurrencyEffect.plus(
new Big(priceInBaseCurrency)
.div(new Big(previousPriceInBaseCurrency))
.minus(1)
.mul(portfolioWeight)
);
newTotalInvestment = newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
);
return {
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
};
}
private getCurrency(symbol: string) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = this.activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
private getHoldings(start: Date, end: Date) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) => parseDate(h) >= end) &&
Object.keys(this.holdings).some((h) => parseDate(h) <= start)
) {
return this.holdings;
}
this.computeHoldings(start, end);
return this.holdings;
}
private computeHoldings(start: Date, end: Date) {
const investmentByDate = this.getInvestmentByDate();
const transactionDates = Object.keys(investmentByDate).sort();
let dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
let currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const date = dates[i];
const previousDate = dates[i - 1];
if (transactionDates.some((d) => d === date.toDateString())) {
let holdings = { ...currentHoldings[previousDate.toDateString()] };
investmentByDate[date.toDateString()].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[date.toDateString()] = holdings;
} else {
currentHoldings[date.toDateString()] =
currentHoldings[previousDate.toDateString()];
}
}
this.holdings = currentHoldings;
}
private calculateInitialHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
currentHoldings: { [date: string]: { [symbol: string]: Big } }
) {
const preRangeTrades = Object.keys(investmentByDate)
.filter((date) => resetHours(new Date(date)) <= start)
.map((date) => investmentByDate[date])
.reduce((a, b) => a.concat(b), [])
.reduce((groupBySymbol, trade) => {
if (!groupBySymbol[trade.SymbolProfile.symbol]) {
groupBySymbol[trade.SymbolProfile.symbol] = [];
}
groupBySymbol[trade.SymbolProfile.symbol].push(trade);
return groupBySymbol;
}, {});
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
let startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[start.toDateString()][symbol] = startQuantity;
}
}
private getInvestmentByDate(): { [date: string]: PortfolioOrder[] } {
return this.activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
}

17
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -8,13 +8,15 @@ import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
}
@Injectable()
@ -74,6 +76,19 @@ export class PortfolioCalculatorFactory {
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');
}

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

@ -53,12 +53,12 @@ export abstract class PortfolioCalculator {
protected activities: PortfolioOrder[];
private configurationService: ConfigurationService;
private currency: string;
protected currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
protected exchangeRateDataService: ExchangeRateDataService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
@ -66,6 +66,7 @@ export abstract class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
public constructor({
accountBalanceItems,
@ -288,6 +289,8 @@ export abstract class PortfolioCalculator {
}
}
this.marketMap = marketSymbolMap;
const endDateString = format(endDate, DATE_FORMAT);
if (firstIndex > 0) {

54
apps/api/src/app/portfolio/portfolio.service.ts

@ -740,7 +740,9 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
userId,
activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL', 'STAKE'].includes(
order.type
);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
@ -1279,10 +1281,20 @@ export class PortfolioService {
let currentNetWorth = 0;
const items = await portfolioCalculator.getChart({
let items = await portfolioCalculator.getChart({
dateRange
});
items = await this.calculatedTimeWeightedPerformance(
calculateTimeWeightedPerformance,
activities,
dateRange,
userId,
userCurrency,
filters,
items
);
const itemOfToday = items.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
@ -1330,6 +1342,44 @@ export class PortfolioService {
};
}
private async calculatedTimeWeightedPerformance(
calculateTimeWeightedPerformance: boolean,
activities: Activity[],
dateRange: string,
userId: string,
userCurrency: string,
filters: Filter[],
items: HistoricalDataItem[]
) {
if (calculateTimeWeightedPerformance) {
const portfolioCalculatorCPR = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.CPR,
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
let timeWeightedInvestmentItems = await portfolioCalculatorCPR.getChart({
dateRange
});
items = items.map((item) => {
let matchingItem = timeWeightedInvestmentItems.find(
(timeWeightedInvestmentItem) =>
timeWeightedInvestmentItem.date === item.date
);
item.timeWeightedPerformance = matchingItem.netPerformanceInPercentage;
item.timeWeightedPerformanceWithCurrencyEffect =
matchingItem.netPerformanceInPercentageWithCurrencyEffect;
return item;
});
}
return items;
}
@LogPerformance
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId, this.request.user.id);

1
libs/common/src/lib/interfaces/historical-data-item.interface.ts

@ -17,5 +17,6 @@ export interface HistoricalDataItem {
value?: number;
valueInPercentage?: number;
timeWeightedPerformance?: number;
timeWeightedPerformanceWithCurrencyEffect?: number;
valueWithCurrencyEffect?: number;
}

Loading…
Cancel
Save