You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ghostfolio/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

375 lines
12 KiB

import { PositionDetailDialogParams } from '@ghostfolio/client/components/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
HistoricalDataItem,
PortfolioInvestments,
PortfolioPerformance,
Position,
User
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { isNumber, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-analysis-page',
styleUrls: ['./analysis-page.scss'],
templateUrl: './analysis-page.html'
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[];
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public daysInMarket: number;
public deviceType: string;
public dividendsByGroup: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`;
public firstOrderDate: Date;
public hasImpersonationId: boolean;
public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean;
public isLoadingDividendTimelineChart: boolean;
public isLoadingInvestmentChart: boolean;
public isLoadingInvestmentTimelineChart: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' },
{ label: $localize`Yearly`, value: 'year' }
];
public performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks'];
public top3: Position[];
public unitCurrentStreak: string;
public unitLongestStreak: string;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private dialog: MatDialog,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
}
get savingsRate() {
const savingsRatePerMonth =
this.hasImpersonationId || this.user.settings.isRestrictedView
? undefined
: this.user?.settings?.savingsRate;
return this.mode === 'year'
? savingsRatePerMonth * 12
: savingsRatePerMonth;
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.benchmark = this.benchmarks.find(({ id }) => {
return id === this.user.settings?.benchmark;
});
this.update();
}
});
}
public onChangeBenchmark(symbolProfileId: string) {
this.dataService
.putUserSetting({ benchmark: symbolProfileId })
.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) {
this.mode = aMode;
this.fetchDividendsAndInvestments();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchDividendsAndInvestments() {
this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true;
this.dataService
.fetchDividends({
filters: this.userService.getFilters(),
groupBy: this.mode,
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dividends }) => {
this.dividendsByGroup = dividends;
this.isLoadingDividendTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchInvestments({
filters: this.userService.getFilters(),
groupBy: this.mode,
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ investments, streaks }) => {
this.investmentsByGroup = investments;
this.streaks = streaks;
this.unitCurrentStreak =
this.mode === 'year'
? this.streaks?.currentStreak === 1
? translate('YEAR')
: translate('YEARS')
: this.streaks?.currentStreak === 1
? translate('MONTH')
: translate('MONTHS');
this.unitLongestStreak =
this.mode === 'year'
? this.streaks?.longestStreak === 1
? translate('YEAR')
: translate('YEARS')
: this.streaks?.longestStreak === 1
? translate('MONTH')
: translate('MONTHS');
this.isLoadingInvestmentTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
}
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() {
this.isLoadingInvestmentChart = true;
this.dataService
.fetchPortfolioPerformance({
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart, firstOrderDate, performance }) => {
this.firstOrderDate = firstOrderDate ?? new Date();
this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
this.investments = [];
this.performance = performance;
this.performanceDataItems = [];
this.performanceDataItemsInPercentage = [];
for (const [
index,
{
date,
netPerformanceInPercentageWithCurrencyEffect,
totalInvestmentValueWithCurrencyEffect,
valueInPercentage,
valueWithCurrencyEffect
}
] of chart.entries()) {
if (index > 0 || this.user?.settings?.dateRange === 'max') {
// Ignore first item where value is 0
this.investments.push({
date,
investment: totalInvestmentValueWithCurrencyEffect
});
this.performanceDataItems.push({
date,
value: isNumber(valueWithCurrencyEffect)
? valueWithCurrencyEffect
: valueInPercentage
});
}
this.performanceDataItemsInPercentage.push({
date,
value: netPerformanceInPercentageWithCurrencyEffect
});
}
this.isLoadingInvestmentChart = false;
this.updateBenchmarkDataItems();
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPositions({
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
const positionsSorted = sortBy(
positions.filter(({ netPerformancePercentageWithCurrencyEffect }) => {
return isNumber(netPerformancePercentageWithCurrencyEffect);
}),
'netPerformancePercentageWithCurrencyEffect'
).reverse();
this.top3 = positionsSorted.slice(0, 3);
if (positions?.length > 3) {
this.bottom3 = positionsSorted.slice(-3).reverse();
} else {
this.bottom3 = [];
}
this.changeDetectorRef.markForCheck();
});
this.fetchDividendsAndInvestments();
this.changeDetectorRef.markForCheck();
}
private updateBenchmarkDataItems() {
this.benchmarkDataItems = [];
if (this.user.settings.benchmark) {
const { dataSource, symbol } =
this.benchmarks.find(({ id }) => {
return id === this.user.settings.benchmark;
}) ?? {};
if (dataSource && symbol) {
this.isLoadingBenchmarkComparator = true;
this.dataService
.fetchBenchmarkBySymbol({
dataSource,
symbol,
range: this.user?.settings?.dateRange,
startDate: this.firstOrderDate
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.benchmarkDataItems = marketData.map(({ date, value }) => {
return {
date,
value
};
});
this.isLoadingBenchmarkComparator = false;
this.changeDetectorRef.markForCheck();
});
}
}
}
}