Browse Source

Merge Conflict Fixes + remove cpr and add calculate networth to base portfolio calculator

pull/5027/head
Dan 2 months ago
parent
commit
68dcd4006b
  1. 595
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  2. 34
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  3. 271
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  4. 6
      apps/api/src/app/portfolio/portfolio.controller.ts
  5. 25
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 3
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  7. 79
      package-lock.json

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

@ -1,595 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Inject, Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addDays,
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
subDays
} from 'date-fns';
import { CurrentRateService } from '../../current-rate.service';
import { DateQuery } from '../../interfaces/date-query.interface';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { RoaiPortfolioCalculator } from '../roai/portfolio-calculator';
export class CPRPortfolioCalculator extends RoaiPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
constructor(
{
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
userId,
filters
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
exchangeRateDataService: ExchangeRateDataService;
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
filters: Filter[];
userId: string;
},
@Inject()
private orderService: OrderService
) {
super({
accountBalanceItems,
activities,
configurationService,
currency,
filters,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
userId
});
}
@LogPerformance
public async getPerformanceWithTimeWeightedReturn({
start,
end
}: {
start: Date;
end: Date;
}): Promise<{ chart: HistoricalDataItem[] }> {
const item = await super.getPerformance({
end,
start
});
const itemResult = item.chart;
const dates = itemResult.map((item) => parseDate(item.date));
const timeWeighted = await this.getTimeWeightedChartData({
dates
});
item.chart = itemResult.map((itemInt) => {
const timeWeightedItem = timeWeighted.find(
(timeWeightedItem) => timeWeightedItem.date === itemInt.date
);
if (timeWeightedItem) {
itemInt.timeWeightedPerformance =
timeWeightedItem.netPerformanceInPercentage;
itemInt.timeWeightedPerformanceWithCurrencyEffect =
timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect;
}
return itemInt;
});
return item;
}
@LogPerformance
public async getUnfilteredNetWorth(currency: string): Promise<Big> {
const activities = await this.orderService.getOrders({
userId: this.userId,
userCurrency: currency,
types: ['BUY', 'SELL', 'STAKE'],
withExcludedAccounts: true
});
const orders = this.activitiesToPortfolioOrder(activities.activities);
const start = orders.reduce(
(date, order) =>
parseDate(date.date).getTime() < parseDate(order.date).getTime()
? date
: order,
{ date: orders[0].date }
).date;
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getValues({
dataGatheringItems: this.mapToDataGatheringItems(orders),
dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT);
const exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => {
const symbolCurrency = this.getCurrencyFromActivities(orders, holding);
const exchangeRate =
await this.exchangeRateDataService.toCurrencyAtDate(
1,
symbolCurrency,
this.currency,
end
);
return { symbolCurrency, exchangeRate };
})
);
const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>(
(all, currency): { [currency: string]: number } => {
all[currency.symbolCurrency] ??= currency.exchangeRate;
return all;
},
{}
);
const totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
}
@LogPerformance
protected async getTimeWeightedChartData({
dates
}: {
dates?: Date[];
}): Promise<HistoricalDataItem[]> {
dates = dates.sort((a, b) => a.getTime() - b.getTime());
const start = dates[0];
const end = dates[dates.length - 1];
const marketMapTask = this.computeMarketMap({
gte: start,
lt: addDays(end, 1)
});
const timelineHoldings = await this.getHoldings(
this.activities,
start,
end
);
const data: HistoricalDataItem[] = [];
const startString = format(start, DATE_FORMAT);
data.push({
date: startString,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
this.marketMap = await marketMapTask;
let totalInvestment = Object.keys(timelineHoldings[startString]).reduce(
(sum, holding) => {
return sum.plus(
timelineHoldings[startString][holding].mul(
this.marketMap[startString][holding] ?? new Big(0)
)
);
},
new Big(0)
);
let previousNetPerformanceInPercentage = new Big(0);
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (let i = 1; i < dates.length; i++) {
const date = format(dates[i], DATE_FORMAT);
const previousDate = format(dates[i - 1], DATE_FORMAT);
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
.plus(1)
.mul(netPerformanceInPercentage.plus(1))
.minus(1);
previousNetPerformanceInPercentageWithCurrencyEffect =
previousNetPerformanceInPercentageWithCurrencyEffect
.plus(1)
.mul(netPerformanceInPercentageWithCurrencyEffect.plus(1))
.minus(1);
data.push({
date,
netPerformanceInPercentage: previousNetPerformanceInPercentage
.mul(100)
.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
previousNetPerformanceInPercentageWithCurrencyEffect
.mul(100)
.toNumber()
});
}
return data;
}
@LogPerformance
protected async handleSingleHolding(
previousDate: string,
holding: string,
date: string,
totalInvestment: Big,
timelineHoldings: { [date: string]: { [symbol: string]: Big } },
netPerformanceInPercentage: Big,
netPerformanceInPercentageWithCurrencyEffect: Big,
newTotalInvestment: Big
) {
const previousPrice =
Object.keys(this.marketMap).indexOf(previousDate) > 0
? this.marketMap[previousDate][holding]
: undefined;
const priceDictionary = this.marketMap[date];
let currentPrice =
priceDictionary !== undefined ? priceDictionary[holding] : previousPrice;
currentPrice ??= previousPrice;
const previousHolding = timelineHoldings[previousDate][holding];
const priceInBaseCurrency = currentPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
currentPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(date)
)
)
: new Big(0);
if (previousHolding.eq(0)) {
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
if (previousPrice === undefined || currentPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`,
'PortfolioCalculator'
);
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
const previousPriceInBaseCurrency = previousPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
previousPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(previousDate)
)
)
: new Big(0);
const portfolioWeight = totalInvestment.toNumber()
? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment)
: new Big(0);
netPerformanceInPercentage = netPerformanceInPercentage.plus(
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight)
);
netPerformanceInPercentageWithCurrencyEffect =
netPerformanceInPercentageWithCurrencyEffect.plus(
priceInBaseCurrency
.div(previousPriceInBaseCurrency)
.minus(1)
.mul(portfolioWeight)
);
newTotalInvestment = newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
);
return {
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
};
}
@LogPerformance
protected getCurrency(symbol: string) {
return this.getCurrencyFromActivities(this.activities, symbol);
}
@LogPerformance
protected getCurrencyFromActivities(
activities: PortfolioOrder[],
symbol: string
) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
@LogPerformance
protected async getHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) =>
isAfter(parseDate(h), subDays(end, 1))
) &&
Object.keys(this.holdings).some((h) =>
isBefore(parseDate(h), addDays(start, 1))
)
) {
return this.holdings;
}
this.computeHoldings(activities, start, end);
return this.holdings;
}
@LogPerformance
protected async computeHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
const investmentByDate = this.getInvestmentByDate(activities);
this.calculateHoldings(investmentByDate, start, end);
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
end: Date
) {
const transactionDates = Object.keys(investmentByDate).sort();
const dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
const holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
@LogPerformance
protected 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;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
const startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
@LogPerformance
protected getInvestmentByDate(activities: PortfolioOrder[]): {
[date: string]: PortfolioOrder[];
} {
return activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
@LogPerformance
protected mapToDataGatheringItems(
orders: PortfolioOrder[]
): IDataGatheringItem[] {
return orders
.map((activity) => {
return {
symbol: activity.SymbolProfile.symbol,
dataSource: activity.SymbolProfile.dataSource
};
})
.filter(
(gathering, i, arr) =>
arr.findIndex((t) => t.symbol === gathering.symbol) === i
);
}
@LogPerformance
protected async computeMarketMap(dateQuery: DateQuery): Promise<{
[date: string]: { [symbol: string]: Big };
}> {
const dataGatheringItems: IDataGatheringItem[] =
this.mapToDataGatheringItems(this.activities);
const { values: marketSymbols } = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
return marketSymbolMap;
}
@LogPerformance
protected activitiesToPortfolioOrder(
activities: Activity[]
): PortfolioOrder[] {
return activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
}

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

@ -10,19 +10,13 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
ROAI = 'ROAI', // Return on Average Investment
TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
}
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
@ -30,7 +24,8 @@ export class PortfolioCalculatorFactory {
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService
private readonly redisCacheService: RedisCacheService,
private readonly orderService: OrderService
) {}
@LogPerformance
@ -61,7 +56,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.ROAI:
@ -75,7 +71,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.ROI:
@ -89,7 +86,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.TWR:
@ -103,19 +101,7 @@ export class PortfolioCalculatorFactory {
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
});
case PerformanceCalculationType.CPR:
return new RoaiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
orderService: this.orderService,
filters
});

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

@ -41,6 +41,7 @@ import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
addDays,
differenceInDays,
eachDayOfInterval,
endOfDay,
@ -52,6 +53,8 @@ import {
} from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
import { OrderService } from '../../order/order.service';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
@ -64,6 +67,7 @@ export abstract class PortfolioCalculator {
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
protected exchangeRateDataService: ExchangeRateDataService;
protected orderService: OrderService;
private filters: Filter[];
private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService;
@ -73,6 +77,8 @@ export abstract class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
protected userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
public constructor({
accountBalanceItems,
@ -84,7 +90,8 @@ export abstract class PortfolioCalculator {
filters,
portfolioSnapshotService,
redisCacheService,
userId
userId,
orderService
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
@ -96,6 +103,7 @@ export abstract class PortfolioCalculator {
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
userId: string;
orderService: OrderService;
}) {
this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
@ -103,6 +111,7 @@ export abstract class PortfolioCalculator {
this.currentRateService = currentRateService;
this.exchangeRateDataService = exchangeRateDataService;
this.filters = filters;
this.orderService = orderService;
let dateOfFirstActivity = new Date();
@ -613,9 +622,77 @@ export abstract class PortfolioCalculator {
};
}
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
@LogPerformance
public async getUnfilteredNetWorth(currency: string): Promise<Big> {
const activities = await this.orderService.getOrders({
userId: this.userId,
userCurrency: currency,
types: ['BUY', 'SELL', 'STAKE'],
withExcludedAccounts: true
});
const orders = this.activitiesToPortfolioOrder(activities.activities);
const start = orders.reduce(
(date, order) =>
parseDate(date.date).getTime() < parseDate(order.date).getTime()
? date
: order,
{ date: orders[0].date }
).date;
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getValues({
dataGatheringItems: this.mapToDataGatheringItems(orders),
dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT);
const exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => {
const symbolCurrency = this.getCurrencyFromActivities(orders, holding);
const exchangeRate =
await this.exchangeRateDataService.toCurrencyAtDate(
1,
symbolCurrency,
this.currency,
end
);
return { symbolCurrency, exchangeRate };
})
);
const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>(
(all, currency): { [currency: string]: number } => {
all[currency.symbolCurrency] ??= currency.exchangeRate;
return all;
},
{}
);
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
const totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
}
@LogPerformance
public getDataProviderInfos() {
@ -807,6 +884,25 @@ export abstract class PortfolioCalculator {
return this.snapshot;
}
@LogPerformance
protected getCurrency(symbol: string) {
return this.getCurrencyFromActivities(this.activities, symbol);
}
@LogPerformance
protected getCurrencyFromActivities(
activities: PortfolioOrder[],
symbol: string
) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
@LogPerformance
protected computeTransactionPoints() {
this.transactionPoints = [];
@ -1030,6 +1126,173 @@ export abstract class PortfolioCalculator {
}
}
@LogPerformance
protected activitiesToPortfolioOrder(
activities: Activity[]
): PortfolioOrder[] {
return activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
@LogPerformance
protected async getHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) =>
isAfter(parseDate(h), subDays(end, 1))
) &&
Object.keys(this.holdings).some((h) =>
isBefore(parseDate(h), addDays(start, 1))
)
) {
return this.holdings;
}
this.computeHoldings(activities, start, end);
return this.holdings;
}
@LogPerformance
protected async computeHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
const investmentByDate = this.getInvestmentByDate(activities);
this.calculateHoldings(investmentByDate, start, end);
}
@LogPerformance
protected 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;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
const startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
@LogPerformance
protected getInvestmentByDate(activities: PortfolioOrder[]): {
[date: string]: PortfolioOrder[];
} {
return activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
@LogPerformance
protected mapToDataGatheringItems(
orders: PortfolioOrder[]
): IDataGatheringItem[] {
return orders
.map((activity) => {
return {
symbol: activity.SymbolProfile.symbol,
dataSource: activity.SymbolProfile.dataSource
};
})
.filter(
(gathering, i, arr) =>
arr.findIndex((t) => t.symbol === gathering.symbol) === i
);
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
end: Date
) {
const transactionDates = Object.keys(investmentByDate).sort();
const dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
const holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
private getChartDateMap({
endDate,
startDate,
@ -1117,4 +1380,6 @@ export abstract class PortfolioCalculator {
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
}

6
apps/api/src/app/portfolio/portfolio.controller.ts

@ -506,8 +506,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -522,8 +521,7 @@ export class PortfolioController {
filters,
impersonationId,
withExcludedAccounts,
userId: this.request.user.id,
calculateTimeWeightedPerformance
userId: this.request.user.id
});
if (

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

@ -86,7 +86,6 @@ import {
} from 'date-fns';
import { isEmpty, uniqBy } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
@ -1102,15 +1101,13 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
userId,
calculateTimeWeightedPerformance = false
userId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean;
}): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
@ -1158,11 +1155,7 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const range = { end: endDate, start: startDate };
const { chart } = await (calculateTimeWeightedPerformance
? (
portfolioCalculator as CPRPortfolioCalculator
).getPerformanceWithTimeWeightedReturn(range)
: portfolioCalculator.getPerformance(range));
const { chart } = await portfolioCalculator.getPerformance(range);
const {
netPerformance,
@ -1932,17 +1925,9 @@ export class PortfolioService {
.plus(totalOfExcludedActivities)
.toNumber();
const netWorth =
portfolioCalculator instanceof CPRPortfolioCalculator
? await (portfolioCalculator as CPRPortfolioCalculator)
.getUnfilteredNetWorth(this.getUserCurrency())
.then((value) => value.toNumber())
: new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
const netWorth = await portfolioCalculator
.getUnfilteredNetWorth(this.getUserCurrency())
.then((value) => value.toNumber());
const daysInMarket = differenceInDays(new Date(), firstOrderDate);

3
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -210,9 +210,8 @@ export class DataGatheringProcessor {
name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherMissingHistoricalMarketData(job: Job<IDataGatheringItem>) {
const { dataSource, date, symbol } = job.data;
try {
const { dataSource, date, symbol } = job.data;
Logger.log(
`Historical market data gathering for missing values has been started for ${symbol} (${dataSource}) at ${format(
date,

79
package-lock.json

@ -13231,6 +13231,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/array-union": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz",
"integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/array.prototype.findlastindex": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
@ -17238,6 +17250,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
@ -19803,61 +19827,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz",
"integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==",
"dev": true,
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.3",
"path-type": "^6.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz",
"integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/globby/node_modules/path-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/good-listener": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",

Loading…
Cancel
Save