diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 4a09141ca..aece7a927 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -63,7 +63,7 @@ export class PortfolioCalculator { this.orders.sort((a, b) => a.date?.localeCompare(b.date)); } - public computeTransactionPoints() { + public computeTransactionPoints(types: TypeOfOrder[] = ['BUY', 'SELL']) { this.transactionPoints = []; const symbols: { [symbol: string]: TransactionPointSymbol } = {}; @@ -85,17 +85,21 @@ export class PortfolioCalculator { let investment = new Big(0); if (newQuantity.gt(0)) { - if (order.type === 'BUY') { + if (order.type === 'BUY' && types.includes('BUY')) { investment = oldAccumulatedSymbol.investment.plus( order.quantity.mul(unitPrice) ); - } else if (order.type === 'SELL') { + } else if (order.type === 'SELL' && types.includes('SELL')) { const averagePrice = oldAccumulatedSymbol.investment.div( oldAccumulatedSymbol.quantity ); investment = oldAccumulatedSymbol.investment.minus( order.quantity.mul(averagePrice) ); + } else if (order.type === 'DIVIDEND' && types.includes('DIVIDEND')) { + investment = oldAccumulatedSymbol.investment.plus( + order.quantity.mul(unitPrice) + ); } } @@ -492,9 +496,10 @@ export class PortfolioCalculator { } currentDate = parseDate(order.date); + investmentByMonth = order.quantity - .mul(order.unitPrice) - .mul(this.getFactor(order.type)); + .mul(order.unitPrice) + .mul(this.getFactor(order.type)); } if (index === this.orders.length - 1) { @@ -816,6 +821,9 @@ export class PortfolioCalculator { case 'SELL': factor = -1; break; + case 'DIVIDEND': + factor = 1; + break; default: factor = 0; break; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index ef0b586e5..9c925900a 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -14,6 +14,7 @@ import { parseDate } from '@ghostfolio/common/helper'; import { PortfolioDetails, PortfolioInvestments, + PortfolioDividends, PortfolioPerformanceResponse, PortfolioPublicDetails, PortfolioReport @@ -230,6 +231,58 @@ export class PortfolioController { return { investments }; } + @Get('dividends') + @UseGuards(AuthGuard('jwt')) + public async getDividends( + @Headers('impersonation-id') impersonationId: string, + @Query('range') dateRange: DateRange = 'max', + @Query('groupBy') groupBy?: GroupBy + ): Promise { + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let dividends: InvestmentItem[]; + + if (groupBy === 'month') { + dividends = await this.portfolioService.getInvestments({ + dateRange, + impersonationId, + groupBy: 'month', + orderTypes: ['DIVIDEND'] + }); + } else { + dividends = await this.portfolioService.getInvestments({ + dateRange, + impersonationId, + orderTypes: ['DIVIDEND'] + }); + } + + if ( + impersonationId || + this.userService.isRestrictedView(this.request.user) + ) { + const maxInvestment = dividends.reduce( + (investment, item) => Math.max(investment, item.investment), + 1 + ); + + dividends = dividends.map((item) => ({ + date: item.date, + investment: item.investment / maxInvestment + })); + } + + return { dividends }; + } + @Get('performance') @UseGuards(AuthGuard('jwt')) @UseInterceptors(TransformDataSourceInResponseInterceptor) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a34d1e385..34e33cd74 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -210,18 +210,21 @@ export class PortfolioService { public async getInvestments({ dateRange, impersonationId, - groupBy + groupBy, + orderTypes = ['BUY', 'SELL'] }: { dateRange: DateRange; impersonationId: string; groupBy?: GroupBy; + orderTypes?: TypeOfOrder[] }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ userId, - includeDrafts: true + includeDrafts: true, + orderTypes: orderTypes }); const portfolioCalculator = new PortfolioCalculator({ @@ -1370,12 +1373,14 @@ export class PortfolioService { filters, includeDrafts = false, userId, - withExcludedAccounts + withExcludedAccounts, + orderTypes = ['BUY', 'SELL'] }: { filters?: Filter[]; includeDrafts?: boolean; userId: string; withExcludedAccounts?: boolean; + orderTypes?: TypeOfOrder[] }): Promise<{ transactionPoints: TransactionPoint[]; orders: OrderWithAccount[]; @@ -1390,7 +1395,7 @@ export class PortfolioService { userCurrency, userId, withExcludedAccounts, - types: ['BUY', 'SELL'] + types: orderTypes }); if (orders.length <= 0) { 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 c7442e1f3..900ba6064 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 @@ -34,6 +34,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public hasImpersonationId: boolean; public investments: InvestmentItem[]; public investmentsByMonth: InvestmentItem[]; + public dividendsByMonth: InvestmentItem[]; public isLoadingBenchmarkComparator: boolean; public isLoadingInvestmentChart: boolean; public mode: GroupBy = 'month'; @@ -165,7 +166,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); - this.dataService + this.dataService .fetchInvestments({ groupBy: 'month', range: this.user?.settings?.dateRange @@ -177,7 +178,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); - this.dataService + this.dataService .fetchPositions({ range: this.user?.settings?.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ positions }) => { @@ -197,6 +198,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); + this.dataService + .fetchDividends({ + groupBy: 'month', + range: this.user?.settings?.dateRange + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ dividends }) => { + this.dividendsByMonth = dividends; + + this.changeDetectorRef.markForCheck(); + }); + 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 6fff81da3..b861a7ce4 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -168,4 +168,40 @@ + +
+
+
+
+ Dividends Timeline + +
+ +
+
+ +
+
+
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 1e65af36a..6022530ae 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -26,6 +26,7 @@ import { InfoItem, OAuthResponse, PortfolioDetails, + PortfolioDividends, PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPublicDetails, @@ -182,6 +183,19 @@ export class DataService { ); } + public fetchDividends({ + groupBy, + range + }: { + groupBy?: 'month'; + range: DateRange; + }) { + return this.http.get( + '/api/v1/portfolio/dividends', + { params: { groupBy, range } } + ); + } + public fetchSymbolItem({ dataSource, includeHistoricalData, diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index ef93845c5..049ca32ca 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -19,6 +19,7 @@ import { InfoItem } from './info-item.interface'; import { LineChartItem } from './line-chart-item.interface'; import { PortfolioChart } from './portfolio-chart.interface'; import { PortfolioDetails } from './portfolio-details.interface'; +import { PortfolioDividends } from './portfolio-dividends.interface'; import { PortfolioInvestments } from './portfolio-investments.interface'; import { PortfolioItem } from './portfolio-item.interface'; import { PortfolioOverview } from './portfolio-overview.interface'; @@ -63,6 +64,7 @@ export { PortfolioChart, PortfolioDetails, PortfolioInvestments, + PortfolioDividends, PortfolioItem, PortfolioOverview, PortfolioPerformance, diff --git a/libs/common/src/lib/interfaces/portfolio-dividends.interface.ts b/libs/common/src/lib/interfaces/portfolio-dividends.interface.ts new file mode 100644 index 000000000..585c46bb7 --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-dividends.interface.ts @@ -0,0 +1,5 @@ +import { InvestmentItem } from './investment-item.interface'; + +export interface PortfolioDividends { + dividends: InvestmentItem[]; +}