Browse Source

Feature/rework portfolio calculator (#3393)

* Rework portfolio calculation

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/3694/head 2.106.0-alpha.1
gizmodus 1 month ago
committed by GitHub
parent
commit
f360a12823
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 2
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  3. 19
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  4. 735
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  5. 54
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  6. 52
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  7. 58
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  8. 74
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  9. 36
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  10. 62
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  11. 36
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  12. 7
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  13. 18
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  14. 35
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  15. 52
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  16. 77
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  17. 295
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  18. 17
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  19. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  20. 248
      apps/api/src/app/portfolio/portfolio.service.ts
  21. 47
      apps/api/src/app/redis-cache/redis-cache.service.ts
  22. 3
      apps/api/src/app/user/user.controller.ts
  23. 16
      apps/api/src/app/user/user.service.ts
  24. 8
      apps/api/src/events/portfolio-changed.listener.ts
  25. 3
      apps/api/src/services/configuration/configuration.service.ts
  26. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  27. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  28. 88
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  29. 2
      apps/client/src/app/components/home-overview/home-overview.component.ts
  30. 24
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  31. 26
      libs/common/src/lib/calculation-helper.ts
  32. 16
      libs/common/src/lib/class-transformer.ts
  33. 4
      libs/common/src/lib/interfaces/portfolio-performance.interface.ts
  34. 4
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  35. 2
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  36. 6
      libs/common/src/lib/interfaces/symbol-metrics.interface.ts
  37. 46
      libs/common/src/lib/models/portfolio-snapshot.ts
  38. 16
      libs/common/src/lib/models/timeline-position.ts
  39. 27
      libs/ui/src/lib/assistant/assistant.component.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Reworked the portfolio calculator
## 2.105.0 - 2024-08-21
### Added

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

@ -16,7 +16,6 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
@ -24,7 +23,6 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};

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

@ -3,8 +3,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -31,30 +30,23 @@ export class PortfolioCalculatorFactory {
activities,
calculationType,
currency,
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
filters = [],
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
filters?: Filter[];
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
useCache,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -67,8 +59,7 @@ export class PortfolioCalculatorFactory {
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
filters,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,

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

@ -19,13 +19,14 @@ import {
import {
AssetProfileIdentifier,
DataProviderInfo,
Filter,
HistoricalDataItem,
InvestmentItem,
ResponseError,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -37,12 +38,10 @@ import {
format,
isAfter,
isBefore,
isSameDay,
max,
min,
subDays
} from 'date-fns';
import { first, last, uniq, uniqBy } from 'lodash';
import { first, isNumber, last, sortBy, sum, uniq, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
@ -54,15 +53,14 @@ export abstract class PortfolioCalculator {
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private filters: Filter[];
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string;
public constructor({
@ -71,10 +69,9 @@ export abstract class PortfolioCalculator {
configurationService,
currency,
currentRateService,
dateRange,
exchangeRateDataService,
filters,
redisCacheService,
useCache,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
@ -82,18 +79,19 @@ export abstract class PortfolioCalculator {
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
filters: Filter[];
redisCacheService: RedisCacheService;
useCache: boolean;
userId: string;
}) {
this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
this.currency = currency;
this.currentRateService = currentRateService;
this.dateRange = dateRange;
this.exchangeRateDataService = exchangeRateDataService;
this.filters = filters;
let dateOfFirstActivity = new Date();
this.activities = activities
.map(
@ -106,10 +104,14 @@ export abstract class PortfolioCalculator {
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
if (isBefore(date, dateOfFirstActivity)) {
dateOfFirstActivity = date;
}
if (isAfter(date, new Date())) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
date = endOfDay(new Date());
}
return {
@ -128,10 +130,12 @@ export abstract class PortfolioCalculator {
});
this.redisCacheService = redisCacheService;
this.useCache = useCache;
this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange(
'max',
subDays(dateOfFirstActivity, 1)
);
this.endDate = endDate;
this.startDate = startDate;
@ -145,38 +149,18 @@ export abstract class PortfolioCalculator {
positions: TimelinePosition[]
): PortfolioSnapshot;
public async computeSnapshot(
start: Date,
end?: Date
): Promise<PortfolioSnapshot> {
private async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints);
let endDate = end;
if (!endDate) {
endDate = new Date(Date.now());
if (lastTransactionPoint) {
endDate = max([endDate, parseDate(lastTransactionPoint.date)]);
}
}
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
return isBefore(parseDate(date), endDate);
return isBefore(parseDate(date), this.endDate);
});
if (!transactionPoints.length) {
return {
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
historicalData: [],
positions: [],
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
@ -189,15 +173,12 @@ export abstract class PortfolioCalculator {
const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
let dates: Date[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
let totalValuablesWithCurrencyEffect = new Big(0);
dates.push(resetHours(start));
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
@ -211,47 +192,19 @@ export abstract class PortfolioCalculator {
for (let i = 0; i < transactionPoints.length; i++) {
if (
!isBefore(parseDate(transactionPoints[i].date), start) &&
!isBefore(parseDate(transactionPoints[i].date), this.startDate) &&
firstTransactionPoint === null
) {
firstTransactionPoint = transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(transactionPoints[i].date)));
}
}
dates.push(resetHours(endDate));
// Add dates of last week for fallback
dates.push(subDays(resetHours(new Date()), 7));
dates.push(subDays(resetHours(new Date()), 6));
dates.push(subDays(resetHours(new Date()), 5));
dates.push(subDays(resetHours(new Date()), 4));
dates.push(subDays(resetHours(new Date()), 3));
dates.push(subDays(resetHours(new Date()), 2));
dates.push(subDays(resetHours(new Date()), 1));
dates.push(resetHours(new Date()));
dates = uniq(
dates.map((date) => {
return date.getTime();
})
)
.map((timestamp) => {
return new Date(timestamp);
})
.sort((a, b) => {
return a.getTime() - b.getTime();
});
let exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
endDate: endOfDay(endDate),
startDate: this.getStartDate(),
endDate: endOfDay(this.endDate),
startDate: this.startDate,
targetCurrency: this.currency
});
@ -262,7 +215,8 @@ export abstract class PortfolioCalculator {
} = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery: {
in: dates
gte: this.startDate,
lt: this.endDate
}
});
@ -286,7 +240,19 @@ export abstract class PortfolioCalculator {
}
}
const endDateString = format(endDate, DATE_FORMAT);
const endDateString = format(this.endDate, DATE_FORMAT);
const daysInMarket = differenceInDays(this.endDate, this.startDate);
let chartDateMap = this.getChartDateMap({
endDate: this.endDate,
startDate: this.startDate,
step: Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS))
});
const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => {
return chartDate;
});
if (firstIndex > 0) {
firstIndex--;
@ -297,6 +263,35 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
const accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
};
} = {};
const valuesBySymbol: {
[symbol: string]: {
currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big };
investmentValuesAccumulated: { [date: string]: Big };
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
investmentValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
};
} = {};
for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
@ -313,16 +308,25 @@ export abstract class PortfolioCalculator {
);
const {
currentValues,
currentValuesWithCurrencyEffect,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
hasErrors,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestment,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
timeWeightedInvestmentWithCurrencyEffect,
totalDividend,
totalDividendInBaseCurrency,
@ -332,17 +336,30 @@ export abstract class PortfolioCalculator {
totalLiabilitiesInBaseCurrency,
totalValuablesInBaseCurrency
} = this.getSymbolMetrics({
chartDateMap,
marketSymbolMap,
start,
dataSource: item.dataSource,
end: endDate,
end: this.endDate,
exchangeRates:
exchangeRatesByCurrency[`${item.currency}${this.currency}`],
start: this.startDate,
symbol: item.symbol
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
valuesBySymbol[item.symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
positions.push({
feeInBaseCurrency,
timeWeightedInvestment,
@ -374,11 +391,11 @@ export abstract class PortfolioCalculator {
netPerformancePercentage: !hasErrors
? (netPerformancePercentage ?? null)
: null,
netPerformancePercentageWithCurrencyEffect: !hasErrors
? (netPerformancePercentageWithCurrencyEffect ?? null)
netPerformancePercentageWithCurrencyEffectMap: !hasErrors
? (netPerformancePercentageWithCurrencyEffectMap ?? null)
: null,
netPerformanceWithCurrencyEffect: !hasErrors
? (netPerformanceWithCurrencyEffect ?? null)
netPerformanceWithCurrencyEffectMap: !hasErrors
? (netPerformanceWithCurrencyEffectMap ?? null)
: null,
quantity: item.quantity,
symbol: item.symbol,
@ -411,205 +428,9 @@ export abstract class PortfolioCalculator {
}
}
const overall = this.calculateOverallPerformance(positions);
return {
...overall,
errors,
positions,
totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
public async getChart({
dateRange = 'max',
withDataDecimation = true
}: {
dateRange?: DateRange;
withDataDecimation?: boolean;
}): Promise<HistoricalDataItem[]> {
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
this.getStartDate()
);
const daysInMarket = differenceInDays(endDate, startDate) + 1;
const step = withDataDecimation
? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS))
: 1;
return this.getChartData({
step,
end: endDate,
start: startDate
});
}
public async getChartData({
end = new Date(Date.now()),
start,
step = 1
}: {
end?: Date;
start: Date;
step?: number;
}): Promise<HistoricalDataItem[]> {
const symbols: { [symbol: string]: boolean } = {};
const transactionPointsBeforeEndDate =
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
const firstIndex = transactionPointsBeforeEndDate.length;
let dates = eachDayOfInterval({ start, end }, { step }).map((date) => {
return resetHours(date);
});
const includesEndDate = isSameDay(last(dates), end);
if (!includesEndDate) {
dates.push(resetHours(end));
}
if (transactionPointsBeforeEndDate.length > 0) {
for (const {
currency,
dataSource,
symbol
} of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource,
symbol
});
currencies[symbol] = currency;
symbols[symbol] = true;
}
}
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
dataGatheringItems,
dateQuery: {
in: dates
}
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
let exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
endDate: endOfDay(end),
startDate: this.getStartDate(),
targetCurrency: this.currency
});
for (const marketSymbol of marketSymbols) {
const dateString = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[dateString]) {
marketSymbolMap[dateString] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
};
} = {};
const valuesBySymbol: {
[symbol: string]: {
currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big };
investmentValuesAccumulated: { [date: string]: Big };
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
investmentValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
};
} = {};
for (const symbol of Object.keys(symbols)) {
const {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
dataSource: null,
exchangeRates:
exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`],
isChartMode: true
});
valuesBySymbol[symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
}
let lastDate = format(this.startDate, DATE_FORMAT);
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: new Big(0),
totalAccountBalanceWithCurrencyEffect: new Big(0),
totalCurrentValue: new Big(0),
totalCurrentValueWithCurrencyEffect: new Big(0),
totalInvestmentValue: new Big(0),
totalInvestmentValueWithCurrencyEffect: new Big(0),
totalNetPerformanceValue: new Big(0),
totalNetPerformanceValueWithCurrencyEffect: new Big(0),
totalTimeWeightedInvestmentValue: new Big(0),
totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0)
};
let lastDate = chartDates[0];
for (const dateString of chartDates) {
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
@ -647,91 +468,63 @@ export abstract class PortfolioCalculator {
dateString
] ?? new Big(0);
accumulatedValuesByDate[dateString].investmentValueWithCurrencyEffect =
accumulatedValuesByDate[
dateString
].investmentValueWithCurrencyEffect.add(
investmentValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalCurrentValue =
accumulatedValuesByDate[dateString].totalCurrentValue.add(
currentValue
);
accumulatedValuesByDate[
dateString
].totalCurrentValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalCurrentValueWithCurrencyEffect.add(
currentValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalInvestmentValue =
accumulatedValuesByDate[dateString].totalInvestmentValue.add(
investmentValueAccumulated
);
accumulatedValuesByDate[
dateString
].totalInvestmentValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalInvestmentValueWithCurrencyEffect.add(
investmentValueAccumulatedWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalNetPerformanceValue =
accumulatedValuesByDate[dateString].totalNetPerformanceValue.add(
netPerformanceValue
);
accumulatedValuesByDate[
dateString
].totalNetPerformanceValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalNetPerformanceValueWithCurrencyEffect.add(
netPerformanceValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalTimeWeightedInvestmentValue =
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValue.add(timeWeightedInvestmentValue);
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValueWithCurrencyEffect =
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValueWithCurrencyEffect.add(
timeWeightedInvestmentValueWithCurrencyEffect
);
}
if (
this.accountBalanceItems.some(({ date }) => {
return date === dateString;
})
) {
accumulatedValuesByDate[
dateString
].totalAccountBalanceWithCurrencyEffect = new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
);
} else {
accumulatedValuesByDate[
dateString
].totalAccountBalanceWithCurrencyEffect =
accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some(
({ date }) => {
return date === dateString;
}
)
? new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
)
: (accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)),
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalCurrentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
).add(currentValueWithCurrencyEffect),
totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValueAccumulated),
totalInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueAccumulatedWithCurrencyEffect),
totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue),
totalNetPerformanceValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
).add(netPerformanceValueWithCurrencyEffect),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(timeWeightedInvestmentValueWithCurrencyEffect)
};
}
lastDate = dateString;
}
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const historicalData: HistoricalDataItem[] = Object.entries(
accumulatedValuesByDate
).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect,
@ -749,7 +542,6 @@ export abstract class PortfolioCalculator {
? 0
: totalNetPerformanceValue
.div(totalTimeWeightedInvestmentValue)
.mul(100)
.toNumber();
const netPerformanceInPercentageWithCurrencyEffect =
@ -757,7 +549,6 @@ export abstract class PortfolioCalculator {
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect)
.mul(100)
.toNumber();
return {
@ -781,6 +572,19 @@ export abstract class PortfolioCalculator {
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber()
};
});
const overall = this.calculateOverallPerformance(positions);
return {
...overall,
errors,
historicalData,
positions,
totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
public getDataProviderInfos() {
@ -861,6 +665,70 @@ export abstract class PortfolioCalculator {
return this.snapshot;
}
public async getPerformance({ end, start }) {
await this.snapshotPromise;
const { historicalData } = this.snapshot;
const chart: HistoricalDataItem[] = [];
let netPerformanceAtStartDate: number;
let netPerformanceWithCurrencyEffectAtStartDate: number;
let totalInvestmentValuesWithCurrencyEffect: number[] = [];
for (let historicalDataItem of historicalData) {
const date = resetHours(parseDate(historicalDataItem.date));
if (!isBefore(date, start) && !isAfter(date, end)) {
if (!isNumber(netPerformanceAtStartDate)) {
netPerformanceAtStartDate = historicalDataItem.netPerformance;
netPerformanceWithCurrencyEffectAtStartDate =
historicalDataItem.netPerformanceWithCurrencyEffect;
}
const netPerformanceSinceStartDate =
historicalDataItem.netPerformance - netPerformanceAtStartDate;
const netPerformanceWithCurrencyEffectSinceStartDate =
historicalDataItem.netPerformanceWithCurrencyEffect -
netPerformanceWithCurrencyEffectAtStartDate;
if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) {
totalInvestmentValuesWithCurrencyEffect.push(
historicalDataItem.totalInvestmentValueWithCurrencyEffect
);
}
const timeWeightedInvestmentValue =
totalInvestmentValuesWithCurrencyEffect.length > 0
? sum(totalInvestmentValuesWithCurrencyEffect) /
totalInvestmentValuesWithCurrencyEffect.length
: 0;
chart.push({
...historicalDataItem,
netPerformance:
historicalDataItem.netPerformance - netPerformanceAtStartDate,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectSinceStartDate,
netPerformanceInPercentage:
netPerformanceSinceStartDate / timeWeightedInvestmentValue,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue,
// TODO: Add net worth with valuables
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber()
netWorth: 0
});
}
}
return { chart };
}
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
@ -889,23 +757,21 @@ export abstract class PortfolioCalculator {
}
protected abstract getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
isChartMode,
marketSymbolMap,
start,
step,
symbol
}: {
chartDateMap: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() {
@ -918,6 +784,66 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}) {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
let chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (let date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of recent dates
for (let date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (let dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap;
}
private computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
@ -1057,52 +983,47 @@ export abstract class PortfolioCalculator {
}
private async initialize() {
if (this.useCache) {
const startTimeTotal = performance.now();
const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
})
);
const cachedSnapshot = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters,
userId: this.userId
})
);
if (cachedSnapshot) {
this.snapshot = plainToClass(
PortfolioSnapshot,
JSON.parse(cachedSnapshot)
);
if (cachedSnapshot) {
this.snapshot = plainToClass(
PortfolioSnapshot,
JSON.parse(cachedSnapshot)
);
Logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
} else {
this.snapshot = await this.computeSnapshot(
this.startDate,
this.endDate
);
Logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
} else {
this.snapshot = await this.computeSnapshot();
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
}),
JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL')
);
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters,
userId: this.userId
}),
JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL')
);
Logger.debug(
`Computed portfolio snapshot in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
}
} else {
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
Logger.debug(
`Computed portfolio snapshot in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
}
}
}

54
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -123,43 +122,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
@ -178,12 +156,12 @@ describe('PortfolioCalculator', () => {
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
@ -205,6 +183,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -108,43 +107,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.0440867739678096571'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
@ -165,10 +143,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
@ -188,6 +168,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -93,43 +92,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.09004392386530014641'
),
grossPerformanceWithCurrencyEffect: new Big('24.6'),
hasErrors: false,
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
positions: [
{
averagePrice: new Big('136.6'),
@ -150,10 +128,18 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('273.2'),
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.08437042459736456808')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period
'1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
'5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period
wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period
ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'),
@ -173,6 +159,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,
netPerformanceWithCurrencyEffect: 23.05,
totalInvestmentValueWithCurrencyEffect: 273.2
})
);
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);

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

@ -18,6 +18,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -78,11 +79,10 @@ describe('PortfolioCalculator', () => {
);
});
describe('get current positions', () => {
// TODO
describe.skip('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime());
const activities: Activity[] = [
{
@ -121,43 +121,23 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2015-01-01')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2015-01-01')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('13298.425356'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
positions: [
{
averagePrice: new Big('320.43'),
@ -168,32 +148,32 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformance: new Big('27172.74').mul(0.97373),
grossPerformancePercentage: new Big('0.4241983590271396608571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
'0.4164017412624815597008'
),
grossPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
investment: new Big('320.43'),
investment: new Big('320.43').mul(0.97373),
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
marketPrice: 13657.2,
marketPriceInBaseCurrency: 13298.425356,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
netPerformance: new Big('27172.74').mul(0.97373),
netPerformancePercentage: new Big('0.4241983590271396608571'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.417188277288666871633')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('26516.208701400000064086')
},
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'),
timeWeightedInvestment: new Big('623.73914366102470265325'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024'
'636.79389574611155533947'
),
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
@ -201,12 +181,22 @@ describe('PortfolioCalculator', () => {
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43'),
totalInvestment: new Big('320.43').mul(0.97373),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('27172.74').mul(0.97373).toNumber(),
netPerformanceInPercentage: 42.41983590271396609433,
netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854,
netPerformanceWithCurrencyEffect: 26516.208701400000064086,
totalInvestmentValueWithCurrencyEffect: 318.542667299999967957
})
);
expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') }

36
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => {
it.only('with fee activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -93,28 +92,15 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
spy.mockRestore();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('0'),
@ -135,8 +121,8 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 0,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [],
@ -153,6 +139,16 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
});
});
});

62
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts

@ -18,6 +18,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -80,9 +81,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
@ -106,43 +105,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2023-01-03')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-01-03')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
hasErrors: false,
netPerformance: new Big('26.33'),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
positions: [
{
averagePrice: new Big('89.12'),
@ -153,26 +131,28 @@ describe('PortfolioCalculator', () => {
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'),
grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12'),
investment: new Big('89.12').mul(0.8854),
investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33'),
netPerformance: new Big('26.33').mul(0.8854),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.851974')
},
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
symbol: 'GOOGL',
tags: [],
timeWeightedInvestment: new Big('89.12'),
timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
@ -180,12 +160,22 @@ describe('PortfolioCalculator', () => {
],
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'),
totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974,
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
expect(investments).toEqual([
{ date: '2023-01-03', investment: new Big('89.12') }
]);

36
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
@ -93,28 +92,15 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('500000'),
@ -135,8 +121,8 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 500000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [],
@ -153,6 +139,16 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
});
});
});

7
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts

@ -67,9 +67,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => {
it.only('with liability activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
@ -93,12 +91,9 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
spy.mockRestore();
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();

18
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -18,6 +18,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -80,9 +81,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
@ -121,15 +120,10 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-07-10')
);
spy.mockRestore();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],
@ -160,6 +154,12 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 298.58
})
);
});
});
});

35
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts

@ -13,6 +13,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { subDays } from 'date-fns';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -63,45 +64,28 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it('with no orders', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const start = subDays(new Date(Date.now()), 10);
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
historicalData: [],
positions: [],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
@ -113,12 +97,7 @@ describe('PortfolioCalculator', () => {
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([
{
date: '2021-12-01',
investment: 0
}
]);
expect(investmentsByMonth).toEqual([]);
});
});
});

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
@ -108,43 +107,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.15113417083448194384'
),
grossPerformanceWithCurrencyEffect: new Big('21.93'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.12184460284330327256'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.12184460284330327256'
),
netPerformanceWithCurrencyEffect: new Big('17.68'),
positions: [
{
averagePrice: new Big('75.80'),
@ -165,10 +143,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.12184460284330327256'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.12184460284330327256'
),
netPerformanceWithCurrencyEffect: new Big('17.68'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.12348284960422163588')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('17.68')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),
@ -190,6 +170,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 17.68,
netPerformanceInPercentage: 0.12184460284330327256,
netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256,
netPerformanceWithCurrencyEffect: 17.68,
totalInvestmentValueWithCurrencyEffect: 75.8
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -67,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
@ -108,28 +107,34 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(chartData[0]).toEqual({
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
@ -144,12 +149,16 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 151.6
});
expect(chartData[chartData.length - 1]).toEqual({
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
@ -159,22 +168,10 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
hasErrors: false,
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
netPerformanceWithCurrencyEffect: new Big('19.86'),
positions: [
{
averagePrice: new Big('0'),
@ -195,10 +192,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
netPerformanceWithCurrencyEffect: new Big('19.86'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
@ -218,6 +217,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }

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

@ -1,12 +1,14 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -14,6 +16,7 @@ import {
addDays,
addMilliseconds,
differenceInDays,
eachDayOfInterval,
format,
isBefore
} from 'date-fns';
@ -28,7 +31,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformanceWithCurrencyEffect = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
let totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
@ -73,11 +75,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect.plus(
currentPosition.netPerformanceWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
@ -103,57 +100,34 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
return {
currentValueInBaseCurrency,
grossPerformance,
grossPerformanceWithCurrencyEffect,
hasErrors,
netPerformance,
netPerformanceWithCurrencyEffect,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: netPerformance.div(totalTimeWeightedInvestment),
netPerformancePercentageWithCurrencyEffect:
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: netPerformanceWithCurrencyEffect.div(
totalTimeWeightedInvestmentWithCurrencyEffect
),
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: grossPerformance.div(totalTimeWeightedInvestment),
grossPerformancePercentageWithCurrencyEffect:
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: grossPerformanceWithCurrencyEffect.div(
totalTimeWeightedInvestmentWithCurrencyEffect
),
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
@ -229,10 +203,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
@ -279,10 +253,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffect: new Big(0),
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
@ -333,39 +307,43 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let day = start;
let lastUnitPrice: Big;
if (isChartMode) {
const datesWithOrders = {};
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
while (isBefore(day, end)) {
const dateString = format(day, DATE_FORMAT);
for (const { date, type } of orders) {
if (['BUY', 'SELL'].includes(type)) {
datesWithOrders[date] = true;
if (ordersByDate[dateString]?.length > 0) {
for (let order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else if (chartDateMap[dateString]) {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
while (isBefore(day, end)) {
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
if (!hasDate) {
orders.push({
date: format(day, DATE_FORMAT),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
}
const lastOrder = last(orders);
lastUnitPrice = last(orders).unitPrice;
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
day = addDays(day, step);
}
day = addDays(day, 1);
}
// Sort orders so that the start and end placeholder order are at the correct
@ -456,12 +434,14 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
);
}
if (order.unitPrice) {
order.unitPriceInBaseCurrency = order.unitPrice.mul(
currentExchangeRate ?? 1
);
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
@ -645,10 +625,13 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder && ['BUY', 'SELL'].includes(order.type)) {
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (valueOfInvestmentBeforeTransaction.gt(0)) {
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
@ -683,44 +666,42 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
);
}
if (isChartMode) {
currentValues[order.date] = valueOfInvestment;
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
@ -762,11 +743,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const totalNetPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
@ -812,14 +788,99 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalNetPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of <DateRange[]>[
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
]) {
// TODO: getIntervalFromDateRange(dateRange, start)
let { endDate, startDate } = getIntervalFromDateRange(dateRange);
if (isBefore(startDate, start)) {
startDate = start;
}
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[format(startDate, DATE_FORMAT)] ??
new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[
format(startDate, DATE_FORMAT)
] ?? new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
const dates = eachDayOfInterval({
end: endDate,
start: startDate
}).map((date) => {
return format(date, DATE_FORMAT);
});
let average = new Big(0);
let dayCount = 0;
for (const date of dates) {
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[
format(endDate, DATE_FORMAT)
]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[
format(startDate, DATE_FORMAT)
] ?? new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
@ -854,9 +915,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed(
2
)} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%`
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
@ -872,9 +933,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
@ -893,7 +955,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:

17
apps/api/src/app/portfolio/current-rate.service.mock.ts

@ -1,6 +1,12 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import {
addDays,
eachDayOfInterval,
endOfDay,
isBefore,
isSameDay
} from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
@ -24,6 +30,10 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 139.9 };
} else if (isSameDay(parseDate('2021-11-30'), date)) {
return { marketPrice: 136.6 };
} else if (isSameDay(parseDate('2021-12-12'), date)) {
return { marketPrice: 142.0 };
} else if (isSameDay(parseDate('2021-12-17'), date)) {
return { marketPrice: 143.9 };
} else if (isSameDay(parseDate('2021-12-18'), date)) {
return { marketPrice: 148.9 };
}
@ -97,7 +107,10 @@ export const CurrentRateServiceMock = {
}
}
} else {
for (const date of dateQuery.in) {
for (const date of eachDayOfInterval({
end: dateQuery.lt,
start: dateQuery.gte
})) {
for (const dataGatheringItem of dataGatheringItems) {
values.push({
date,

1
apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts

@ -6,6 +6,7 @@ export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: 'end' | 'start';
unitPriceFromMarketData?: Big;
unitPriceInBaseCurrency?: Big;
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
}

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

@ -67,12 +67,13 @@ import {
differenceInDays,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
parseISO,
set
} from 'date-fns';
import { isEmpty, uniq, uniqBy } from 'lodash';
import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import {
@ -244,6 +245,8 @@ export class PortfolioService {
}): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({
filters,
userId,
@ -261,18 +264,16 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: this.request.user.Settings.settings.baseCurrency
});
const items = await portfolioCalculator.getChart({
dateRange,
withDataDecimation: false
const { historicalData } = await portfolioCalculator.getSnapshot();
const items = historicalData.filter(({ date }) => {
return !isBefore(date, startDate) && !isAfter(date, endDate);
});
let investments: InvestmentItem[];
@ -340,13 +341,10 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user?.Settings.settings.isExperimentalFeatures
currency: userCurrency
});
const { currentValueInBaseCurrency, hasErrors, positions } =
@ -400,10 +398,8 @@ export class PortfolioService {
};
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(dataGatheringItems);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
@ -427,8 +423,8 @@ export class PortfolioService {
marketPrice,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffectMap,
quantity,
symbol,
tags,
@ -448,7 +444,6 @@ export class PortfolioService {
}
const assetProfile = symbolProfileMap[symbol];
const dataProviderResponse = dataProviderResponses[symbol];
let markets: PortfolioPosition['markets'];
let marketsAdvanced: PortfolioPosition['marketsAdvanced'];
@ -495,14 +490,15 @@ export class PortfolioService {
}
),
investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? 0,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? 0,
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
@ -571,7 +567,6 @@ export class PortfolioService {
if (withSummary) {
summary = await this.getSummary({
filteredValueInBaseCurrency,
holdings,
impersonationId,
portfolioCalculator,
userCurrency,
@ -657,10 +652,7 @@ export class PortfolioService {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: userCurrency
});
const portfolioStart = portfolioCalculator.getStartDate();
@ -809,9 +801,11 @@ export class PortfolioService {
netPerformance: position.netPerformance?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect:
position.netPerformancePercentageWithCurrencyEffect?.toNumber(),
position.netPerformancePercentageWithCurrencyEffectMap?.[
'max'
]?.toNumber(),
netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffect?.toNumber(),
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(),
@ -930,13 +924,10 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: this.request.user.Settings.settings.baseCurrency
});
let { hasErrors, positions } = await portfolioCalculator.getSnapshot();
@ -995,8 +986,8 @@ export class PortfolioService {
investmentWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffectMap,
quantity,
symbol,
timeWeightedInvestment,
@ -1029,9 +1020,12 @@ export class PortfolioService {
netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? null,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? null,
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ??
null,
quantity: quantity.toNumber(),
timeWeightedInvestment: timeWeightedInvestment?.toNumber(),
timeWeightedInvestmentWithCurrencyEffect:
@ -1046,12 +1040,14 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
portfolioCalculator,
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
portfolioCalculator?: PortfolioCalculator;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> {
@ -1089,7 +1085,7 @@ export class PortfolioService {
)
);
const { endDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
@ -1107,10 +1103,6 @@ export class PortfolioService {
performance: {
currentNetWorth: 0,
currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0,
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
@ -1120,92 +1112,60 @@ export class PortfolioService {
};
}
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
portfolioCalculator =
portfolioCalculator ??
this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
});
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
const {
currentValueInBaseCurrency,
errors,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
hasErrors,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
let currentNetPerformance = netPerformance;
let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
let currentNetWorth = 0;
const items = await portfolioCalculator.getChart({
dateRange
});
const itemOfToday = items.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage
).div(100);
currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100);
currentNetPerformanceWithCurrencyEffect = new Big(
itemOfToday.netPerformanceWithCurrencyEffect
);
currentNetWorth = itemOfToday.netWorth;
}
netWorth,
totalInvestment,
valueWithCurrencyEffect
} =
chart?.length > 0
? last(chart)
: {
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalInvestment: 0,
valueWithCurrencyEffect: 0
};
return {
chart,
errors,
hasErrors,
chart: items,
firstOrderDate: parseDate(items[0]?.date),
firstOrderDate: parseDate(historicalData[0]?.date),
performance: {
currentNetWorth,
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
netPerformance: currentNetPerformance.toNumber(),
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
netPerformance,
netPerformanceWithCurrencyEffect,
totalInvestment,
currentNetWorth: netWorth,
currentValueInBaseCurrency: valueWithCurrencyEffect,
netPerformancePercentage: netPerformanceInPercentage,
netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestment.toNumber()
netPerformanceInPercentageWithCurrencyEffect
}
};
}
@ -1224,10 +1184,7 @@ export class PortfolioService {
activities,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: false,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: this.request.user.Settings.settings.baseCurrency
});
let { totalFeesWithCurrencyEffect, positions, totalInvestment } =
@ -1481,7 +1438,6 @@ export class PortfolioService {
holdings: [],
investment: balance,
marketPrice: 0,
marketState: 'open',
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
@ -1602,7 +1558,6 @@ export class PortfolioService {
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
filteredValueInBaseCurrency,
holdings,
impersonationId,
portfolioCalculator,
userCurrency,
@ -1611,7 +1566,6 @@ export class PortfolioService {
balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big;
holdings: PortfolioDetails['holdings'];
impersonationId: string;
portfolioCalculator: PortfolioCalculator;
userCurrency: string;
@ -1637,18 +1591,20 @@ export class PortfolioService {
}
}
const { currentValueInBaseCurrency, totalInvestment } =
await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({
impersonationId,
userId
});
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
netPerformanceWithCurrencyEffect
} = performance;
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
@ -1745,6 +1701,10 @@ export class PortfolioService {
cash,
excludedAccountsAndActivities,
firstOrderDate,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
@ -1765,21 +1725,15 @@ export class PortfolioService {
fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformanceWithCurrencyEffect: new Big(
netPerformanceWithCurrencyEffect
)
.plus(fees)
.toNumber(),
interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,

47
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -1,9 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import type { RedisCache } from './interfaces/redis-cache.interface';
@ -24,8 +25,34 @@ export class RedisCacheService {
return this.cache.get(key);
}
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
public async getKeys(aPrefix?: string): Promise<string[]> {
let prefix = aPrefix;
if (prefix) {
prefix = `${prefix}*`;
}
return this.cache.store.keys(prefix);
}
public getPortfolioSnapshotKey({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}) {
let portfolioSnapshotKey = `portfolio-snapshot-${userId}`;
if (filters?.length > 0) {
const filtersHash = createHash('sha256')
.update(JSON.stringify(filters))
.digest('hex');
portfolioSnapshotKey = `${portfolioSnapshotKey}-${filtersHash}`;
}
return portfolioSnapshotKey;
}
public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
@ -36,6 +63,20 @@ export class RedisCacheService {
return this.cache.del(key);
}
public async removePortfolioSnapshotsByUserId({
userId
}: {
userId: string;
}) {
const keys = await this.getKeys(
`${this.getPortfolioSnapshotKey({ userId })}`
);
for (const key of keys) {
await this.remove(key);
}
}
public async reset() {
return this.cache.reset();
}

3
apps/api/src/app/user/user.controller.ts

@ -144,6 +144,8 @@ export class UserController {
);
}
const emitPortfolioChangedEvent = 'baseCurrency' in data;
const userSettings: UserSettings = merge(
{},
<UserSettings>this.request.user.Settings.settings,
@ -157,6 +159,7 @@ export class UserController {
}
return this.userService.updateUserSetting({
emitPortfolioChangedEvent,
userSettings,
userId: this.request.user.id
});

16
apps/api/src/app/user/user.service.ts

@ -433,9 +433,11 @@ export class UserService {
}
public async updateUserSetting({
emitPortfolioChangedEvent,
userId,
userSettings
}: {
emitPortfolioChangedEvent: boolean;
userId: string;
userSettings: UserSettings;
}) {
@ -456,12 +458,14 @@ export class UserService {
}
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
if (emitPortfolioChangedEvent) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
}
return settings;
}

8
apps/api/src/events/portfolio-changed.listener.ts

@ -16,10 +16,8 @@ export class PortfolioChangedListener {
'PortfolioChangedListener'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
);
this.redisCacheService.removePortfolioSnapshotsByUserId({
userId: event.getUserId()
});
}
}

3
apps/api/src/services/configuration/configuration.service.ts

@ -4,6 +4,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
import ms from 'ms';
@Injectable()
export class ConfigurationService {
@ -20,7 +21,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),

2
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -20,6 +20,7 @@
</div>
</div>
<!-- TODO
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
@ -32,6 +33,7 @@
[locale]="user?.settings?.locale"
/>
</div>
-->
<div class="mb-3 row">
<div class="col-6 mb-3">

2
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -111,7 +111,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value };
return { x: parseDate(date).getTime(), y: value * 100 };
}),
label: $localize`Portfolio`
},

88
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -37,60 +37,44 @@
<div class="row">
<div class="col-6 mb-3">
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[precision]="netPerformanceWithCurrencyEffectPrecision"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[precision]="netPerformancePrecision"
[unit]="data.baseCurrency"
[value]="netPerformance"
>Change</gf-value
>
}
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[precision]="netPerformanceWithCurrencyEffectPrecision"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
Change with currency effect
} @else {
Change
}
</gf-value>
</div>
<div class="col-6 mb-3">
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercentWithCurrencyEffect"
>Performance with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercent"
>Performance</gf-value
>
}
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercentWithCurrencyEffect"
>
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
Performance with currency effect
} @else {
Performance
}
</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value

2
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -113,7 +113,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: netPerformanceInPercentageWithCurrencyEffect
value: netPerformanceInPercentageWithCurrencyEffect * 100
};
}
);

24
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -80,30 +80,6 @@
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
<ng-container i18n>Gross Performance</ng-container>
<abbr
class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return"
>(TWR)</abbr
>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading
? undefined
: summary?.grossPerformancePercentageWithCurrencyEffect
"
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Fees</div>
<div class="d-flex justify-content-end">

26
libs/common/src/lib/calculation-helper.ts

@ -36,48 +36,36 @@ export function getIntervalFromDateRange(
aDateRange: DateRange,
portfolioStart = new Date(0)
) {
let endDate = endOfDay(new Date(Date.now()));
let endDate = endOfDay(new Date());
let startDate = portfolioStart;
switch (aDateRange) {
case '1d':
startDate = max([
startDate,
subDays(resetHours(new Date(Date.now())), 1)
]);
startDate = max([startDate, subDays(resetHours(new Date()), 1)]);
break;
case 'mtd':
startDate = max([
startDate,
subDays(startOfMonth(resetHours(new Date(Date.now()))), 1)
subDays(startOfMonth(resetHours(new Date())), 1)
]);
break;
case 'wtd':
startDate = max([
startDate,
subDays(
startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }),
1
)
subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1)
]);
break;
case 'ytd':
startDate = max([
startDate,
subDays(startOfYear(resetHours(new Date(Date.now()))), 1)
subDays(startOfYear(resetHours(new Date())), 1)
]);
break;
case '1y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 1)
]);
startDate = max([startDate, subYears(resetHours(new Date()), 1)]);
break;
case '5y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 5)
]);
startDate = max([startDate, subYears(resetHours(new Date()), 5)]);
break;
case 'max':
break;

16
libs/common/src/lib/class-transformer.ts

@ -1,5 +1,21 @@
import { Big } from 'big.js';
export function transformToMapOfBig({
value
}: {
value: { [key: string]: string };
}): {
[key: string]: Big;
} {
const mapOfBig: { [key: string]: Big } = {};
for (const key in value) {
mapOfBig[key] = new Big(value[key]);
}
return mapOfBig;
}
export function transformToBig({ value }: { value: string }): Big {
if (value === null) {
return null;

4
libs/common/src/lib/interfaces/portfolio-performance.interface.ts

@ -2,10 +2,6 @@ export interface PortfolioPerformance {
annualizedPerformancePercent?: number;
currentNetWorth?: number;
currentValueInBaseCurrency: number;
grossPerformance: number;
grossPerformancePercentage: number;
grossPerformancePercentageWithCurrencyEffect: number;
grossPerformanceWithCurrencyEffect: number;
netPerformance: number;
netPerformancePercentage: number;
netPerformancePercentageWithCurrencyEffect: number;

4
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -1,6 +1,7 @@
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Market, MarketAdvanced, MarketState } from '../types';
import { Country } from './country.interface';
import { Holding } from './holding.interface';
import { Sector } from './sector.interface';
@ -28,7 +29,6 @@ export interface PortfolioPosition {
marketPrice: number;
markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: number };
marketState: MarketState;
name: string;
netPerformance: number;
netPerformancePercent: number;

2
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -17,6 +17,8 @@ export interface PortfolioSummary extends PortfolioPerformance {
filteredValueInPercentage?: number;
fireWealth: number;
firstOrderDate: Date;
grossPerformance: number;
grossPerformanceWithCurrencyEffect: number;
interest: number;
items: number;
liabilities: number;

6
libs/common/src/lib/interfaces/symbol-metrics.interface.ts

@ -1,3 +1,5 @@
import { DateRange } from '@ghostfolio/common/types';
import { Big } from 'big.js';
export interface SymbolMetrics {
@ -26,12 +28,12 @@ export interface SymbolMetrics {
};
netPerformance: Big;
netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big;
netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big };
netPerformanceValues: {
[date: string]: Big;
};
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceWithCurrencyEffect: Big;
netPerformanceWithCurrencyEffectMap: { [key: DateRange]: Big };
timeWeightedInvestment: Big;
timeWeightedInvestmentValues: {
[date: string]: Big;

46
libs/common/src/lib/models/portfolio-snapshot.ts

@ -1,5 +1,8 @@
import { transformToBig } from '@ghostfolio/common/class-transformer';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import { Big } from 'big.js';
@ -9,49 +12,12 @@ export class PortfolioSnapshot {
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
currentValueInBaseCurrency: Big;
errors?: AssetProfileIdentifier[];
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
grossPerformance: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
grossPerformanceWithCurrencyEffect: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
grossPerformancePercentage: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
grossPerformancePercentageWithCurrencyEffect: Big;
errors?: AssetProfileIdentifier[];
hasErrors: boolean;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netAnnualizedPerformance?: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netAnnualizedPerformanceWithCurrencyEffect?: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netPerformance: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netPerformanceWithCurrencyEffect: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netPerformancePercentage: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netPerformancePercentageWithCurrencyEffect: Big;
historicalData: HistoricalDataItem[];
@Type(() => TimelinePosition)
positions: TimelinePosition[];

16
libs/common/src/lib/models/timeline-position.ts

@ -1,4 +1,8 @@
import { transformToBig } from '@ghostfolio/common/class-transformer';
import {
transformToBig,
transformToMapOfBig
} from '@ghostfolio/common/class-transformer';
import { DateRange } from '@ghostfolio/common/types';
import { DataSource, Tag } from '@prisma/client';
import { Big } from 'big.js';
@ -65,13 +69,11 @@ export class TimelinePosition {
@Type(() => Big)
netPerformancePercentage: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netPerformancePercentageWithCurrencyEffect: Big;
@Transform(transformToMapOfBig, { toClassOnly: true })
netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big };
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
netPerformanceWithCurrencyEffect: Big;
@Transform(transformToMapOfBig, { toClassOnly: true })
netPerformanceWithCurrencyEffectMap: { [key: DateRange]: Big };
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)

27
libs/ui/src/lib/assistant/assistant.component.ts

@ -231,19 +231,20 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
];
if (this.user?.settings?.isExperimentalFeatures) {
this.dateRangeOptions = this.dateRangeOptions.concat(
eachYearOfInterval({
end: new Date(),
start: this.user?.dateOfFirstActivity ?? new Date()
})
.map((date) => {
return { label: format(date, 'yyyy'), value: format(date, 'yyyy') };
})
.slice(0, -1)
.reverse()
);
}
// TODO
// if (this.user?.settings?.isExperimentalFeatures) {
// this.dateRangeOptions = this.dateRangeOptions.concat(
// eachYearOfInterval({
// end: new Date(),
// start: this.user?.dateOfFirstActivity ?? new Date()
// })
// .map((date) => {
// return { label: format(date, 'yyyy'), value: format(date, 'yyyy') };
// })
// .slice(0, -1)
// .reverse()
// );
// }
this.dateRangeOptions = this.dateRangeOptions.concat([
{

Loading…
Cancel
Save