Browse Source

Time weighted portfolio performance calculation (#2778)

* Implement time weighted portfolio performance calculation

* Update changelog
pull/2787/head
gizmodus 1 year ago
committed by GitHub
parent
commit
b183c45027
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 1
      apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts
  3. 1
      apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts
  4. 9
      apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  5. 9
      apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  6. 1
      apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts
  7. 191
      apps/api/src/app/portfolio/portfolio-calculator.ts
  8. 1
      libs/common/src/lib/interfaces/timeline-position.interface.ts

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Changed the performance calculation to a time-weighted approach
- Used the `HasPermission` annotation in endpoints
## 2.32.0 - 2023-12-26

1
apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -92,6 +92,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
timeWeightedInvestment: new Big('285.8'),
transactionCount: 2
}
],

1
apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts

@ -81,6 +81,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
timeWeightedInvestment: new Big('273.2'),
transactionCount: 1
}
],

9
apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('13657.2'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
netPerformancePercentage: new Big('42.41978276196153750666'),
positions: [
{
averagePrice: new Big('320.43'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
investment: new Big('320.43'),
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
netPerformancePercentage: new Big('42.41978276196153750666'),
marketPrice: 13657.2,
quantity: new Big('1'),
symbol: 'BTCUSD',
timeWeightedInvestment: new Big('640.56763686131386861314'),
transactionCount: 2
}
],

9
apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentage: new Big('0.12184460284330327256'),
positions: [
{
averagePrice: new Big('75.80'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentage: new Big('0.12184460284330327256'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('145.10285714285714285714'),
transactionCount: 2
}
],

1
apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -112,6 +112,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('151.6'),
transactionCount: 2
}
],

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

@ -15,6 +15,7 @@ import {
addMilliseconds,
addMonths,
addYears,
differenceInDays,
endOfDay,
format,
isAfter,
@ -43,7 +44,7 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false;
@ -238,12 +239,13 @@ export class PortfolioCalculator {
}
}
const valuesByDate: {
const accumulatedValuesByDate: {
[date: string]: {
maxTotalInvestmentValue: Big;
totalCurrentValue: Big;
totalInvestmentValue: Big;
totalNetPerformanceValue: Big;
totalTimeWeightedInvestmentValue: Big;
};
} = {};
@ -253,6 +255,7 @@ export class PortfolioCalculator {
investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
};
} = {};
@ -261,7 +264,8 @@ export class PortfolioCalculator {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
netPerformanceValues,
timeWeightedInvestmentValues
} = this.getSymbolMetrics({
end,
marketSymbolMap,
@ -275,7 +279,8 @@ export class PortfolioCalculator {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
netPerformanceValues,
timeWeightedInvestmentValues
};
}
@ -293,38 +298,50 @@ export class PortfolioCalculator {
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
valuesByDate[dateString] = {
accumulatedValuesByDate[dateString] = {
totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValue),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ??
new Big(0)
).add(maxInvestmentValue),
totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue)
};
}
}
return Object.entries(valuesByDate).map(([date, values]) => {
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
totalNetPerformanceValue,
totalTimeWeightedInvestmentValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
let investmentValue =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
? totalTimeWeightedInvestmentValue
: maxTotalInvestmentValue;
const netPerformanceInPercentage = investmentValue.eq(0)
? 0
: totalNetPerformanceValue
.div(maxTotalInvestmentValue)
.mul(100)
.toNumber();
: totalNetPerformanceValue.div(investmentValue).mul(100).toNumber();
return {
date,
@ -447,7 +464,6 @@ export class PortfolioCalculator {
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
@ -461,9 +477,9 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
netPerformancePercentage,
timeWeightedInvestment
} = this.getSymbolMetrics({
end,
marketSymbolMap,
@ -472,9 +488,9 @@ export class PortfolioCalculator {
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({
timeWeightedInvestment,
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
@ -509,7 +525,7 @@ export class PortfolioCalculator {
}
}
const overall = this.calculateOverallPerformance(positions, initialValues);
const overall = this.calculateOverallPerformance(positions);
return {
...overall,
@ -732,18 +748,13 @@ export class PortfolioCalculator {
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [symbol: string]: Big }
) {
private calculateOverallPerformance(positions: TimelinePosition[]) {
let currentValue = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
@ -766,21 +777,9 @@ export class PortfolioCalculator {
hasErrors = true;
}
if (currentPosition.grossPerformancePercentage) {
// Use the average from the initial value and the current investment as
// a weight
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
.plus(currentPosition.investment)
.div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(weight)
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
@ -791,22 +790,18 @@ export class PortfolioCalculator {
}
}
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
totalInvestment,
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: netPerformance.div(totalTimeWeightedInvestment),
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: grossPerformance.div(totalTimeWeightedInvestment)
};
}
@ -1018,6 +1013,7 @@ export class PortfolioCalculator {
let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0);
const currentValues: { [date: string]: Big } = {};
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
@ -1025,12 +1021,12 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
@ -1122,6 +1118,9 @@ export class PortfolioCalculator {
return order.itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
@ -1174,6 +1173,7 @@ export class PortfolioCalculator {
console.log('transactionInvestment', transactionInvestment.toNumber());
}
const totalInvestmentBeforeTransaction = totalInvestment;
totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
@ -1243,7 +1243,35 @@ export class PortfolioCalculator {
grossPerformanceAtStartDate = grossPerformance;
}
if (isChartMode && i > indexOfStartOrder) {
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (totalInvestmentBeforeTransaction.gt(0)) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
// Set to at least 1 day, otherwise the transactions on the same day
// would not be considered in the time weighted calculation
if (daysSinceLastOrder <= 0) {
daysSinceLastOrder = 1;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
totalInvestmentBeforeTransaction.mul(daysSinceLastOrder)
);
}
if (isChartMode) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
@ -1251,6 +1279,12 @@ export class PortfolioCalculator {
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
@ -1274,12 +1308,28 @@ export class PortfolioCalculator {
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
let grossPerformancePercentage: Big;
if (
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
) {
grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
} else {
grossPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
@ -1295,13 +1345,25 @@ export class PortfolioCalculator {
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const netPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
let netPerformancePercentage: Big;
if (
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
) {
netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
} else {
netPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
@ -1318,6 +1380,7 @@ export class PortfolioCalculator {
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
@ -1330,6 +1393,9 @@ export class PortfolioCalculator {
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
@ -1349,9 +1415,12 @@ export class PortfolioCalculator {
maxInvestmentValues,
netPerformancePercentage,
netPerformanceValues,
timeWeightedInvestmentValues,
grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate
};
}

1
libs/common/src/lib/interfaces/timeline-position.interface.ts

@ -16,5 +16,6 @@ export interface TimelinePosition {
quantity: Big;
symbol: string;
tags?: Tag[];
timeWeightedInvestment: Big;
transactionCount: number;
}

Loading…
Cancel
Save