diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a90035b..e4e2bed58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Extended the investment timeline grouped by month + ## 1.167.0 - 07.07.2022 ### Added diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index f117a9a5c..5b21fe146 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -14,8 +14,11 @@ import { format, isAfter, isBefore, + isSameMonth, + isSameYear, max, - min + min, + set } from 'date-fns'; import { first, flatten, isNumber, sortBy } from 'lodash'; @@ -323,6 +326,46 @@ export class PortfolioCalculator { }); } + public getInvestmentsByMonth(): { date: string; investment: Big }[] { + if (this.orders.length === 0) { + return []; + } + + const investments = []; + let currentDate = parseDate(this.orders[0].date); + let investmentByMonth = new Big(0); + + for (const [index, order] of this.orders.entries()) { + if ( + isSameMonth(parseDate(order.date), currentDate) && + isSameYear(parseDate(order.date), currentDate) + ) { + investmentByMonth = investmentByMonth.plus( + order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) + ); + + if (index === this.orders.length - 1) { + investments.push({ + date: format(set(currentDate, { date: 1 }), DATE_FORMAT), + investment: investmentByMonth + }); + } + } else { + investments.push({ + date: format(set(currentDate, { date: 1 }), DATE_FORMAT), + investment: investmentByMonth + }); + + currentDate = parseDate(order.date); + investmentByMonth = order.quantity + .mul(order.unitPrice) + .mul(this.getFactor(order.type)); + } + } + + return investments; + } + public async calculateTimeline( timelineSpecification: TimelineSpecification[], endDate: string diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 70ecbe071..00919b835 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -20,7 +20,12 @@ import { PortfolioReport, PortfolioSummary } from '@ghostfolio/common/interfaces'; -import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; +import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; +import type { + DateRange, + GroupBy, + RequestWithUser +} from '@ghostfolio/common/types'; import { Controller, Get, @@ -217,7 +222,8 @@ export class PortfolioController { @Get('investments') @UseGuards(AuthGuard('jwt')) public async getInvestments( - @Headers('impersonation-id') impersonationId: string + @Headers('impersonation-id') impersonationId: string, + @Query('groupBy') groupBy?: GroupBy ): Promise { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && @@ -229,9 +235,16 @@ export class PortfolioController { ); } - let investments = await this.portfolioService.getInvestments( - impersonationId - ); + let investments: InvestmentItem[]; + + if (groupBy === 'month') { + investments = await this.portfolioService.getInvestments( + impersonationId, + 'month' + ); + } else { + investments = await this.portfolioService.getInvestments(impersonationId); + } if ( impersonationId || diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ab32c8486..9da59b02c 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in import type { AccountWithValue, DateRange, + GroupBy, Market, OrderWithAccount, RequestWithUser @@ -183,7 +184,8 @@ export class PortfolioService { } public async getInvestments( - aImpersonationId: string + aImpersonationId: string, + groupBy?: GroupBy ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); @@ -204,28 +206,39 @@ export class PortfolioService { return []; } - const investments = portfolioCalculator.getInvestments().map((item) => { - return { - date: item.date, - investment: item.investment.toNumber() - }; - }); + let investments: InvestmentItem[]; - // Add investment of today - const investmentOfToday = investments.filter((investment) => { - return investment.date === format(new Date(), DATE_FORMAT); - }); - - if (investmentOfToday.length <= 0) { - const pastInvestments = investments.filter((investment) => { - return isBefore(parseDate(investment.date), new Date()); + if (groupBy === 'month') { + investments = portfolioCalculator.getInvestmentsByMonth().map((item) => { + return { + date: item.date, + investment: item.investment.toNumber() + }; + }); + } else { + investments = portfolioCalculator.getInvestments().map((item) => { + return { + date: item.date, + investment: item.investment.toNumber() + }; }); - const lastInvestment = pastInvestments[pastInvestments.length - 1]; - investments.push({ - date: format(new Date(), DATE_FORMAT), - investment: lastInvestment?.investment ?? 0 + // Add investment of today + const investmentOfToday = investments.filter((investment) => { + return investment.date === format(new Date(), DATE_FORMAT); }); + + if (investmentOfToday.length <= 0) { + const pastInvestments = investments.filter((investment) => { + return isBefore(parseDate(investment.date), new Date()); + }); + const lastInvestment = pastInvestments[pastInvestments.length - 1]; + + investments.push({ + date: format(new Date(), DATE_FORMAT), + investment: lastInvestment?.investment ?? 0 + }); + } } return sortBy(investments, (investment) => { 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 4fae46dbb..65be1387d 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 @@ -22,7 +22,10 @@ import { transformTickToAbbreviation } from '@ghostfolio/common/helper'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; +import { GroupBy } from '@ghostfolio/common/types'; import { + BarController, + BarElement, Chart, LineController, LineElement, @@ -42,6 +45,7 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns'; export class InvestmentChartComponent implements OnChanges, OnDestroy { @Input() currency: string; @Input() daysInMarket: number; + @Input() groupBy: GroupBy; @Input() investments: InvestmentItem[]; @Input() isInPercent = false; @Input() locale: string; @@ -53,6 +57,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { public constructor() { Chart.register( + BarController, + BarElement, LinearScale, LineController, LineElement, @@ -78,7 +84,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { private initialize() { this.isLoading = true; - if (this.investments?.length > 0) { + if (!this.groupBy && this.investments?.length > 0) { // Extend chart by 5% of days in market (before) const firstItem = this.investments[0]; this.investments.unshift({ @@ -102,13 +108,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { } const data = { - labels: this.investments.map((position) => { - return position.date; + labels: this.investments.map((investmentItem) => { + return investmentItem.date; }), datasets: [ { + backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, - borderWidth: 2, + borderWidth: this.groupBy ? 0 : 2, data: this.investments.map((position) => { return position.investment; }), @@ -137,6 +144,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { this.chart = new Chart(this.chartCanvas.nativeElement, { data, options: { + animation: false, elements: { line: { tension: 0 @@ -192,12 +200,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { } }, plugins: [getVerticalHoverLinePlugin(this.chartCanvas)], - type: 'line' + type: this.groupBy ? 'bar' : 'line' }); - - this.isLoading = false; } } + + this.isLoading = false; } private getTooltipPluginConfiguration() { 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 0aa6fc5c3..1f52c8c84 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 @@ -4,6 +4,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso import { UserService } from '@ghostfolio/client/services/user/user.service'; import { Position, User } from '@ghostfolio/common/interfaces'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; +import { GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { differenceInDays } from 'date-fns'; import { sortBy } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -22,6 +23,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public deviceType: string; public hasImpersonationId: boolean; public investments: InvestmentItem[]; + public investmentsByMonth: InvestmentItem[]; + public mode: GroupBy; + public modeOptions: ToggleOption[] = [ + { label: 'Monthly', value: 'month' }, + { label: 'Accumulating', value: undefined } + ]; public top3: Position[]; public user: User; @@ -55,6 +62,15 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); + this.dataService + .fetchInvestmentsByMonth() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ investments }) => { + this.investmentsByMonth = investments; + + this.changeDetectorRef.markForCheck(); + }); + this.dataService .fetchPositions({ range: 'max' }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -86,6 +102,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); } + public onChangeGroupBy(aMode: GroupBy) { + this.mode = aMode; + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); 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 4364a8e83..831c3baec 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -2,8 +2,19 @@

Analysis

-
-
Investment Timeline
+
+
+
+ Investment Timeline +
+ +
+
diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts index e0b5cb423..169134c7b 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; +import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfValueModule } from '@ghostfolio/ui/value'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -14,6 +15,7 @@ import { AnalysisPageComponent } from './analysis-page.component'; AnalysisPageRoutingModule, CommonModule, GfInvestmentChartModule, + GfToggleModule, GfValueModule, MatCardModule, NgxSkeletonLoaderModule diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 250fd1f7f..c3245ce4c 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -204,6 +204,22 @@ export class DataService { ); } + public fetchInvestmentsByMonth(): Observable { + return this.http + .get('/api/v1/portfolio/investments', { + params: { groupBy: 'month' } + }) + .pipe( + map((response) => { + if (response.firstOrderDate) { + response.firstOrderDate = parseISO(response.firstOrderDate); + } + + return response; + }) + ); + } + public fetchSymbolItem({ dataSource, includeHistoricalData, diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index d2c68af26..59be475c0 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -43,7 +43,7 @@ export function getTooltipPositionerMapTop( chart: Chart, position: TooltipPosition ) { - if (!position) { + if (!position || !chart?.chartArea) { return false; } return { diff --git a/libs/common/src/lib/types/group-by.type.ts b/libs/common/src/lib/types/group-by.type.ts new file mode 100644 index 000000000..d4009a721 --- /dev/null +++ b/libs/common/src/lib/types/group-by.type.ts @@ -0,0 +1 @@ +export type GroupBy = 'month'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 7d7050ada..30504dedf 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -2,7 +2,8 @@ import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; import { AccountWithValue } from './account-with-value.type'; import type { DateRange } from './date-range.type'; import type { Granularity } from './granularity.type'; -import { MarketState } from './market-state-type'; +import { GroupBy } from './group-by.type'; +import { MarketState } from './market-state.type'; import { Market } from './market.type'; import type { OrderWithAccount } from './order-with-account.type'; import type { RequestWithUser } from './request-with-user.type'; @@ -13,6 +14,7 @@ export type { AccountWithValue, DateRange, Granularity, + GroupBy, Market, MarketState, OrderWithAccount, diff --git a/libs/common/src/lib/types/market-state-type.ts b/libs/common/src/lib/types/market-state.type.ts similarity index 100% rename from libs/common/src/lib/types/market-state-type.ts rename to libs/common/src/lib/types/market-state.type.ts