Browse Source

Added Time Weighted Performance calculation

Not correct yet
pull/5027/head
Daniel Devaud 2 years ago
parent
commit
5d28c9e6e8
  1. 36
      apps/api/src/app/portfolio/portfolio-calculator.ts
  2. 4
      apps/api/src/app/user/update-user-setting.dto.ts
  3. 1
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  4. 15
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  5. 52
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  6. 18
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  7. 10
      apps/client/src/app/services/data.service.ts
  8. 1
      libs/common/src/lib/interfaces/historical-data-item.interface.ts
  9. 1
      libs/common/src/lib/interfaces/user-settings.interface.ts

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

@ -1585,26 +1585,38 @@ export class PortfolioCalculator {
const previousOrder = orders[i - 1]; const previousOrder = orders[i - 1];
if (order.unitPrice.toNumber() && previousOrder.unitPrice.toNumber()) { if (order.unitPrice.toNumber() && previousOrder.unitPrice.toNumber()) {
netPerformanceValuesPercentage[order.date] = previousOrder.unitPrice netPerformanceValuesPercentage[order.date] = order.unitPrice
.div(order.unitPrice) .div(previousOrder.unitPrice)
.minus(1); .minus(1);
} else if ( } else if (
order.type === 'STAKE' && order.type === 'STAKE' &&
marketSymbolMap[order.date][order.symbol] marketSymbolMap[order.date][order.symbol] &&
((marketSymbolMap[previousOrder.date][
previousOrder.symbol
]?.toNumber() &&
previousOrder.type === 'STAKE') ||
(previousOrder.type !== 'STAKE' &&
previousOrder.unitPrice.toNumber()))
) { ) {
netPerformanceValuesPercentage[order.date] = let previousUnitPrice =
previousOrder.type === 'STAKE' previousOrder.type === 'STAKE'
? marketSymbolMap[previousOrder.date][previousOrder.symbol] ? marketSymbolMap[previousOrder.date][previousOrder.symbol]
: previousOrder.unitPrice : previousOrder.unitPrice;
.div(marketSymbolMap[order.date][order.symbol]) netPerformanceValuesPercentage[order.date] = marketSymbolMap[
.minus(1); order.date
][order.symbol]
? marketSymbolMap[order.date][order.symbol]
.div(previousUnitPrice)
.minus(1)
: new Big(0);
} else if (previousOrder.unitPrice.toNumber()) { } else if (previousOrder.unitPrice.toNumber()) {
netPerformanceValuesPercentage[order.date] = new Big(-1); netPerformanceValuesPercentage[order.date] = new Big(-1);
} else if (previousOrder.type === 'STAKE' && order.unitPrice.toNumber()) { } else if (
netPerformanceValuesPercentage[order.date] = marketSymbolMap[ previousOrder.type === 'STAKE' &&
previousOrder.date marketSymbolMap[previousOrder.date][previousOrder.symbol]?.toNumber()
][previousOrder.symbol] ) {
.div(order.unitPrice) netPerformanceValuesPercentage[order.date] = order.unitPrice
.div(marketSymbolMap[previousOrder.date][previousOrder.symbol])
.minus(1); .minus(1);
} else { } else {
netPerformanceValuesPercentage[order.date] = new Big(0); netPerformanceValuesPercentage[order.date] = new Big(0);

4
apps/api/src/app/user/update-user-setting.dto.ts

@ -68,4 +68,8 @@ export class UpdateUserSettingDto {
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN']) @IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional() @IsOptional()
viewMode?: ViewMode; viewMode?: ViewMode;
@IsIn(<string[]>['N', 'B', 'O'])
@IsOptional()
timeWeightedPerformance?: string;
} }

1
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss

@ -7,5 +7,6 @@
ngx-skeleton-loader { ngx-skeleton-loader {
height: 100%; height: 100%;
} }
margin-bottom: 0.5rem;
} }
} }

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

@ -53,6 +53,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale: string;
@Input() performanceDataItems: LineChartItem[]; @Input() performanceDataItems: LineChartItem[];
@Input() timeWeightedPerformanceDataItems: LineChartItem[];
@Input() user: User; @Input() user: User;
@Output() benchmarkChanged = new EventEmitter<string>(); @Output() benchmarkChanged = new EventEmitter<string>();
@ -83,7 +84,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
permissions.accessAdminControl permissions.accessAdminControl
); );
if (this.performanceDataItems) { if (
this.performanceDataItems ||
this.timeWeightedPerformanceDataItems?.length > 0
) {
this.initialize(); this.initialize();
} }
} }
@ -108,6 +112,15 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}), }),
label: $localize`Portfolio` label: $localize`Portfolio`
}, },
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.timeWeightedPerformanceDataItems.map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Portfolio (time-weighted)`
},
{ {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,

52
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -19,6 +19,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, DataSource, SymbolProfile } from '@prisma/client'; import { AssetClass, DataSource, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { isNumber, sortBy } from 'lodash'; import { isNumber, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -37,6 +38,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[]; public bottom3: Position[];
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public timeWeightedPerformanceOptions = [
{ label: $localize`No`, value: 'N' },
{ label: $localize`Both`, value: 'B' },
{ label: $localize`Only`, value: 'O' }
];
public selectedTimeWeightedPerformanceOption: string;
public daysInMarket: number; public daysInMarket: number;
public deviceType: string; public deviceType: string;
public dividendsByGroup: InvestmentItem[]; public dividendsByGroup: InvestmentItem[];
@ -56,6 +63,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
]; ];
public performanceDataItems: HistoricalDataItem[]; public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[];
public performanceDataItemsTimeWeightedInPercentage: HistoricalDataItem[] =
[];
public placeholder = ''; public placeholder = '';
public portfolioEvolutionDataLabel = $localize`Deposit`; public portfolioEvolutionDataLabel = $localize`Deposit`;
public streaks: PortfolioInvestments['streaks']; public streaks: PortfolioInvestments['streaks'];
@ -212,6 +221,24 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
} }
public onTimeWeightedPerformanceChanged(timeWeightedPerformance: string) {
this.dataService
.putUserSetting({ timeWeightedPerformance })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeGroupBy(aMode: GroupBy) { public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode; this.mode = aMode;
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
@ -314,7 +341,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: this.activeFilters, filters: this.activeFilters,
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange,
timeWeightedPerformance:
this.user?.settings?.timeWeightedPerformance === 'N' ? false : true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart, firstOrderDate }) => { .subscribe(({ chart, firstOrderDate }) => {
@ -324,6 +353,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.investments = []; this.investments = [];
this.performanceDataItems = []; this.performanceDataItems = [];
this.performanceDataItemsInPercentage = []; this.performanceDataItemsInPercentage = [];
this.performanceDataItemsTimeWeightedInPercentage = [];
for (const [ for (const [
index, index,
@ -332,7 +362,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
netPerformanceInPercentage, netPerformanceInPercentage,
totalInvestment, totalInvestment,
value, value,
valueInPercentage valueInPercentage,
timeWeightedPerformance
} }
] of chart.entries()) { ] of chart.entries()) {
if (index > 0 || this.user?.settings?.dateRange === 'max') { if (index > 0 || this.user?.settings?.dateRange === 'max') {
@ -347,6 +378,23 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
date, date,
value: netPerformanceInPercentage value: netPerformanceInPercentage
}); });
if ((this.user?.settings?.timeWeightedPerformance ?? 'N') !== 'N') {
let lastPerformance = 0;
if (index > 0) {
lastPerformance = new Big(
chart[index - 1].timeWeightedPerformance
)
.plus(1)
.mul(new Big(chart[index].timeWeightedPerformance).plus(1))
.minus(1)
.toNumber();
}
chart[index].timeWeightedPerformance = lastPerformance;
this.performanceDataItemsTimeWeightedInPercentage.push({
date,
value: lastPerformance
});
}
} }
this.isLoadingInvestmentChart = false; this.isLoadingInvestmentChart = false;

18
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -17,7 +17,7 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-lg"> <div class="col-lg">
<gf-benchmark-comparator <gf-benchmark-comparator
class="h-100" class="h-auto"
[benchmark]="user?.settings?.benchmark" [benchmark]="user?.settings?.benchmark"
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
@ -26,9 +26,25 @@
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage" [performanceDataItems]="performanceDataItemsInPercentage"
[timeWeightedPerformanceDataItems]="performanceDataItemsTimeWeightedInPercentage"
[user]="user" [user]="user"
(benchmarkChanged)="onChangeBenchmark($event)" (benchmarkChanged)="onChangeBenchmark($event)"
></gf-benchmark-comparator> ></gf-benchmark-comparator>
<div>
<div class="col-md-6 col-xs-12 d-flex">
<div
class="align-items-center d-flex flex-grow-1 h6 mb-0 py-2 text-truncate"
>
<span i18n>Include time-weighted performance </span>
<gf-toggle
[defaultValue]="user?.settings?.timeWeightedPerformance ?? 'N'"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[options]="timeWeightedPerformanceOptions"
(change)="onTimeWeightedPerformanceChanged($event.value)"
></gf-toggle>
</div>
</div>
</div>
</div> </div>
</div> </div>

10
apps/client/src/app/services/data.service.ts

@ -398,11 +398,13 @@ export class DataService {
public fetchPortfolioPerformance({ public fetchPortfolioPerformance({
filters, filters,
range, range,
withExcludedAccounts = false withExcludedAccounts = false,
timeWeightedPerformance = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
range: DateRange; range: DateRange;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
timeWeightedPerformance?: boolean;
}): Observable<PortfolioPerformanceResponse> { }): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('range', range); params = params.append('range', range);
@ -410,6 +412,12 @@ export class DataService {
if (withExcludedAccounts) { if (withExcludedAccounts) {
params = params.append('withExcludedAccounts', withExcludedAccounts); params = params.append('withExcludedAccounts', withExcludedAccounts);
} }
if (timeWeightedPerformance) {
params = params.append(
'timeWeightedPerformance',
timeWeightedPerformance
);
}
return this.http return this.http
.get<any>(`/api/v2/portfolio/performance`, { .get<any>(`/api/v2/portfolio/performance`, {

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

@ -12,4 +12,5 @@ export interface HistoricalDataItem {
totalInvestment?: number; totalInvestment?: number;
value?: number; value?: number;
valueInPercentage?: number; valueInPercentage?: number;
timeWeightedPerformance?: number;
} }

1
libs/common/src/lib/interfaces/user-settings.interface.ts

@ -15,4 +15,5 @@ export interface UserSettings {
retirementDate?: string; retirementDate?: string;
savingsRate?: number; savingsRate?: number;
viewMode?: ViewMode; viewMode?: ViewMode;
timeWeightedPerformance?: string;
} }

Loading…
Cancel
Save