diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0d83e69..f6e694dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for the dividend timeline grouped by year +- Added support for the investment timeline grouped by year - Set up the language localization for Français (`fr`) - Set up the language localization for Português (`pt`) diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts index d826e1d0e..2771a5f60 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts index b8cc6050a..db9c0ac8c 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts @@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index ddb46c9a3..c374ac4c3 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts index 32935a20e..a26426017 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts @@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index f3b3e5881..e0c19ba4b 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts index a27eba341..9ca188444 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => { const investments = portfolioCalculator.getInvestments(); - const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); spy.mockRestore(); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 2d14b9011..48f1e7507 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; +import { GroupBy } from '@ghostfolio/common/types'; import { Logger } from '@nestjs/common'; import { Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; @@ -478,46 +479,60 @@ export class PortfolioCalculator { }); } - public getInvestmentsByMonth(): { date: string; investment: Big }[] { + public getInvestmentsByGroup( + groupBy: GroupBy + ): { date: string; investment: Big }[] { if (this.orders.length === 0) { return []; } const investments = []; let currentDate: Date; - let investmentByMonth = new Big(0); + let investmentByGroup = new Big(0); for (const [index, order] of this.orders.entries()) { if ( - isSameMonth(parseDate(order.date), currentDate) && - isSameYear(parseDate(order.date), currentDate) + isSameYear(parseDate(order.date), currentDate) && + (groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate)) ) { - // Same month: Add up investments + // Same group: Add up investments - investmentByMonth = investmentByMonth.plus( + investmentByGroup = investmentByGroup.plus( order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) ); } else { - // New month: Store previous month and reset + // New group: Store previous group and reset if (currentDate) { investments.push({ - date: format(set(currentDate, { date: 1 }), DATE_FORMAT), - investment: investmentByMonth + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup }); } currentDate = parseDate(order.date); - investmentByMonth = order.quantity + investmentByGroup = order.quantity .mul(order.unitPrice) .mul(this.getFactor(order.type)); } if (index === this.orders.length - 1) { - // Store current month (latest order) + // Store current group (latest order) investments.push({ - date: format(set(currentDate, { date: 1 }), DATE_FORMAT), - investment: investmentByMonth + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup }); } } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 21654f531..02b95ab54 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -235,8 +235,8 @@ export class PortfolioService { }; }); - if (groupBy === 'month') { - dividends = this.getDividendsByMonth(dividends); + if (groupBy) { + dividends = this.getDividendsByGroup({ dividends, groupBy }); } const startDate = this.getStartDate( @@ -282,26 +282,31 @@ export class PortfolioService { let investments: InvestmentItem[]; - if (groupBy === 'month') { - investments = portfolioCalculator.getInvestmentsByMonth().map((item) => { - return { - date: item.date, - investment: item.investment.toNumber() - }; - }); + if (groupBy) { + investments = portfolioCalculator + .getInvestmentsByGroup(groupBy) + .map((item) => { + return { + date: item.date, + investment: item.investment.toNumber() + }; + }); - // Add investment of current month - const dateOfCurrentMonth = format( - set(new Date(), { date: 1 }), + // Add investment of current group + const dateOfCurrentGroup = format( + set(new Date(), { + date: 1, + month: groupBy === 'year' ? 0 : new Date().getMonth() + }), DATE_FORMAT ); - const investmentOfCurrentMonth = investments.filter(({ date }) => { - return date === dateOfCurrentMonth; + const investmentOfCurrentGroup = investments.filter(({ date }) => { + return date === dateOfCurrentGroup; }); - if (investmentOfCurrentMonth.length <= 0) { + if (investmentOfCurrentGroup.length <= 0) { investments.push({ - date: dateOfCurrentMonth, + date: dateOfCurrentGroup, investment: 0 }); } @@ -1264,47 +1269,66 @@ export class PortfolioService { ); } - private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] { - if (aDividends.length === 0) { + private getDividendsByGroup({ + dividends, + groupBy + }: { + dividends: InvestmentItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + if (dividends.length === 0) { return []; } - const dividends = []; + const dividendsByGroup: InvestmentItem[] = []; let currentDate: Date; - let investmentByMonth = new Big(0); + let investmentByGroup = new Big(0); - for (const [index, dividend] of aDividends.entries()) { + for (const [index, dividend] of dividends.entries()) { if ( - isSameMonth(parseDate(dividend.date), currentDate) && - isSameYear(parseDate(dividend.date), currentDate) + isSameYear(parseDate(dividend.date), currentDate) && + (groupBy === 'year' || + isSameMonth(parseDate(dividend.date), currentDate)) ) { - // Same month: Add up divididends + // Same group: Add up dividends - investmentByMonth = investmentByMonth.plus(dividend.investment); + investmentByGroup = investmentByGroup.plus(dividend.investment); } else { - // New month: Store previous month and reset + // New group: Store previous group and reset if (currentDate) { - dividends.push({ - date: format(set(currentDate, { date: 1 }), DATE_FORMAT), - investment: investmentByMonth + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() }); } currentDate = parseDate(dividend.date); - investmentByMonth = new Big(dividend.investment); + investmentByGroup = new Big(dividend.investment); } - if (index === aDividends.length - 1) { + if (index === dividends.length - 1) { // Store current month (latest order) - dividends.push({ - date: format(set(currentDate, { date: 1 }), DATE_FORMAT), - investment: investmentByMonth + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() }); } } - return dividends; + return dividendsByGroup; } private getFees({ diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 249b9cdb6..bfce434e8 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -198,6 +198,15 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { this.chart.options.scales.x.min = this.daysInMarket ? subDays(new Date(), this.daysInMarket).toISOString() : undefined; + + if ( + this.savingsRate && + this.chart.options.plugins.annotation.annotations.savingsRate + ) { + this.chart.options.plugins.annotation.annotations.savingsRate.value = + this.savingsRate; + } + this.chart.update(); } else { this.chart = new Chart(this.chartCanvas.nativeElement, { diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 99c22a076..84a0be896 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -39,19 +39,20 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public daysInMarket: number; public deviceType: string; - public dividendsByMonth: InvestmentItem[]; + public dividendsByGroup: InvestmentItem[]; public dividendTimelineDataLabel = $localize`Dividend`; public filters$ = new Subject(); public firstOrderDate: Date; public hasImpersonationId: boolean; public investments: InvestmentItem[]; public investmentTimelineDataLabel = $localize`Deposit`; - public investmentsByMonth: InvestmentItem[]; + public investmentsByGroup: InvestmentItem[]; public isLoadingBenchmarkComparator: boolean; public isLoadingInvestmentChart: boolean; public mode: GroupBy = 'month'; public modeOptions: ToggleOption[] = [ - { label: $localize`Monthly`, value: 'month' } + { label: $localize`Monthly`, value: 'month' }, + { label: $localize`Yearly`, value: 'year' } ]; public performanceDataItems: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[]; @@ -91,6 +92,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); } + 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; @@ -201,6 +213,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public onChangeGroupBy(aMode: GroupBy) { this.mode = aMode; + this.fetchDividendsAndInvestments(); } public ngOnDestroy() { @@ -208,6 +221,34 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.unsubscribeSubject.complete(); } + private fetchDividendsAndInvestments() { + this.dataService + .fetchDividends({ + filters: this.activeFilters, + groupBy: this.mode, + range: this.user?.settings?.dateRange + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ dividends }) => { + this.dividendsByGroup = dividends; + + this.changeDetectorRef.markForCheck(); + }); + + this.dataService + .fetchInvestments({ + filters: this.activeFilters, + groupBy: this.mode, + range: this.user?.settings?.dateRange + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ investments }) => { + this.investmentsByGroup = investments; + + this.changeDetectorRef.markForCheck(); + }); + } + private openPositionDialog({ dataSource, symbol @@ -291,32 +332,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); - this.dataService - .fetchDividends({ - filters: this.activeFilters, - groupBy: 'month', - range: this.user?.settings?.dateRange - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ dividends }) => { - this.dividendsByMonth = dividends; - - this.changeDetectorRef.markForCheck(); - }); - - this.dataService - .fetchInvestments({ - filters: this.activeFilters, - groupBy: 'month', - range: this.user?.settings?.dateRange - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ investments }) => { - this.investmentsByMonth = investments; - - this.changeDetectorRef.markForCheck(); - }); - this.dataService .fetchPositions({ filters: this.activeFilters, @@ -340,6 +355,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); + this.fetchDividendsAndInvestments(); this.changeDetectorRef.markForCheck(); } diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 2a9cb53f7..e1ba6e4c2 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -180,15 +180,15 @@
@@ -217,11 +217,11 @@