From 7a36408b5792fa9610e077b7425274499f50c3fb Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Jun 2023 21:48:47 +0200 Subject: [PATCH] Add investment streaks * Current streak * Longest streak --- ...ator-btcusd-buy-and-sell-partially.spec.ts | 34 ++++++++++ .../src/app/portfolio/portfolio-calculator.ts | 38 +++++++++-- .../src/app/portfolio/portfolio.controller.ts | 11 +++- .../src/app/portfolio/portfolio.service.ts | 64 +++++++++++++++---- .../analysis/analysis-page.component.ts | 15 ++++- .../portfolio/analysis/analysis-page.html | 20 ++++++ .../portfolio-investments.interface.ts | 1 + libs/ui/src/lib/i18n.ts | 2 + 8 files changed, 166 insertions(+), 19 deletions(-) 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 666ce2167..e0761ebe5 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 @@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => { expect(investmentsByMonth).toEqual([ { date: '2015-01-01', investment: new Big('640.86') }, + { date: '2015-02-01', investment: new Big('0') }, + { date: '2015-03-01', investment: new Big('0') }, + { date: '2015-04-01', investment: new Big('0') }, + { date: '2015-05-01', investment: new Big('0') }, + { date: '2015-06-01', investment: new Big('0') }, + { date: '2015-07-01', investment: new Big('0') }, + { date: '2015-08-01', investment: new Big('0') }, + { date: '2015-09-01', investment: new Big('0') }, + { date: '2015-10-01', investment: new Big('0') }, + { date: '2015-11-01', investment: new Big('0') }, + { date: '2015-12-01', investment: new Big('0') }, + { date: '2016-01-01', investment: new Big('0') }, + { date: '2016-02-01', investment: new Big('0') }, + { date: '2016-03-01', investment: new Big('0') }, + { date: '2016-04-01', investment: new Big('0') }, + { date: '2016-05-01', investment: new Big('0') }, + { date: '2016-06-01', investment: new Big('0') }, + { date: '2016-07-01', investment: new Big('0') }, + { date: '2016-08-01', investment: new Big('0') }, + { date: '2016-09-01', investment: new Big('0') }, + { date: '2016-10-01', investment: new Big('0') }, + { date: '2016-11-01', investment: new Big('0') }, + { date: '2016-12-01', investment: new Big('0') }, + { date: '2017-01-01', investment: new Big('0') }, + { date: '2017-02-01', investment: new Big('0') }, + { date: '2017-03-01', investment: new Big('0') }, + { date: '2017-04-01', investment: new Big('0') }, + { date: '2017-05-01', investment: new Big('0') }, + { date: '2017-06-01', investment: new Big('0') }, + { date: '2017-07-01', investment: new Big('0') }, + { date: '2017-08-01', investment: new Big('0') }, + { date: '2017-09-01', investment: new Big('0') }, + { date: '2017-10-01', investment: new Big('0') }, + { date: '2017-11-01', investment: new Big('0') }, { date: '2017-12-01', investment: new Big('-14156.4') } ]); }); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index f71597e14..9addb29dd 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -544,7 +544,7 @@ export class PortfolioCalculator { return []; } - const investments = []; + const investments: { date: string; investment: Big }[] = []; let currentDate: Date; let investmentByGroup = new Big(0); @@ -554,13 +554,11 @@ export class PortfolioCalculator { (groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate)) ) { // Same group: Add up investments - investmentByGroup = investmentByGroup.plus( order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) ); } else { // New group: Store previous group and reset - if (currentDate) { investments.push({ date: format( @@ -595,7 +593,39 @@ export class PortfolioCalculator { } } - return investments; + // Fill in the missing dates with investment = 0 + const startDate = parseDate(first(this.orders).date); + const endDate = parseDate(last(this.orders).date); + + const allDates: string[] = []; + currentDate = startDate; + + while (currentDate <= endDate) { + allDates.push( + format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ) + ); + currentDate.setMonth(currentDate.getMonth() + 1); + } + + for (const date of allDates) { + const existingInvestment = investments.find((investment) => { + return investment.date === date; + }); + + if (!existingInvestment) { + investments.push({ date, investment: new Big(0) }); + } + } + + return sortBy(investments, (investment) => { + return investment.date; + }); } public async calculateTimeline( diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 8f9c2e579..e79490adb 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -258,11 +258,12 @@ export class PortfolioController { filterByTags }); - let investments = await this.portfolioService.getInvestments({ + let { investments, streaks } = await this.portfolioService.getInvestments({ dateRange, filters, groupBy, - impersonationId + impersonationId, + savingsRate: this.request.user?.Settings?.settings.savingsRate }); if ( @@ -278,6 +279,8 @@ export class PortfolioController { date: item.date, investment: item.investment / maxInvestment })); + + streaks = nullifyValuesInObject(streaks, ['current', 'longest']); } if ( @@ -287,9 +290,11 @@ export class PortfolioController { investments = investments.map((item) => { return nullifyValuesInObject(item, ['investment']); }); + + streaks = nullifyValuesInObject(streaks, ['current', 'longest']); } - return { investments }; + return { investments, streaks }; } @Get('performance') diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 7755146f1..aea522f40 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -28,6 +28,7 @@ import { Filter, HistoricalDataItem, PortfolioDetails, + PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPosition, PortfolioReport, @@ -252,13 +253,15 @@ export class PortfolioService { dateRange, filters, groupBy, - impersonationId + impersonationId, + savingsRate }: { dateRange: DateRange; filters?: Filter[]; groupBy?: GroupBy; impersonationId: string; - }): Promise { + savingsRate: number; + }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = @@ -276,7 +279,10 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); if (transactionPoints.length === 0) { - return []; + return { + investments: [], + streaks: { currentStreak: 0, longestStreak: 0 } + }; } let investments: InvestmentItem[]; @@ -346,9 +352,23 @@ export class PortfolioService { parseDate(investments[0]?.date) ); - return investments.filter(({ date }) => { + investments = investments.filter(({ date }) => { return !isBefore(parseDate(date), startDate); }); + + let streaks: PortfolioInvestments['streaks']; + + if (savingsRate) { + streaks = this.getStreaks({ + investments, + savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate + }); + } + + return { + investments, + streaks + }; } public async getChart({ @@ -1510,6 +1530,28 @@ export class PortfolioService { return portfolioStart; } + private getStreaks({ + investments, + savingsRate + }: { + investments: InvestmentItem[]; + savingsRate: number; + }) { + let currentStreak = 0; + let longestStreak = 0; + + for (const { investment } of investments) { + if (investment >= savingsRate) { + currentStreak++; + longestStreak = Math.max(longestStreak, currentStreak); + } else { + currentStreak = 0; + } + } + + return { currentStreak, longestStreak }; + } + private async getSummary({ balanceInBaseCurrency, emergencyFundPositionsValueInBaseCurrency, @@ -1841,13 +1883,6 @@ export class PortfolioService { return { accounts, platforms }; } - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; - } - private getTotalByType( orders: OrderWithAccount[], currency: string, @@ -1874,4 +1909,11 @@ export class PortfolioService { this.baseCurrency ); } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } } 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 75a66d828..d16e2caf3 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 @@ -10,6 +10,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { Filter, HistoricalDataItem, + PortfolioInvestments, Position, User } from '@ghostfolio/common/interfaces'; @@ -58,6 +59,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public performanceDataItemsInPercentage: HistoricalDataItem[]; public placeholder = ''; public portfolioEvolutionDataLabel = $localize`Deposit`; + public streaks: PortfolioInvestments['streaks']; + public subLabelCurrentStreak: string; + public subLabelLongestStreak: string; public top3: Position[]; public user: User; @@ -242,8 +246,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { range: this.user?.settings?.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ investments }) => { + .subscribe(({ investments, streaks }) => { this.investmentsByGroup = investments; + this.streaks = streaks; + this.subLabelCurrentStreak = + this.mode === 'year' + ? `(${translate('YEARS')})` + : `(${translate('MONTHS')})`; + this.subLabelLongestStreak = + this.mode === 'year' + ? `(${translate('YEARS')})` + : `(${translate('MONTHS')})`; 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 68415e0ae..2044d52a6 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -177,6 +177,26 @@ (change)="onChangeGroupBy($event.value)" > +
+
+ Current Streak +
+
+ Longest Streak +
+