Browse Source

Extend investment timeline by month

pull/1066/head
Thomas 3 years ago
parent
commit
0289597abf
  1. 45
      apps/api/src/app/portfolio/portfolio-calculator.ts
  2. 17
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 50
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 18
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  5. 20
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  6. 26
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  7. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts
  8. 16
      apps/client/src/app/services/data.service.ts
  9. 2
      libs/common/src/lib/chart-helper.ts

45
apps/api/src/app/portfolio/portfolio-calculator.ts

@ -14,8 +14,11 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth,
isSameYear,
max, max,
min min,
set
} from 'date-fns'; } from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash'; 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( public async calculateTimeline(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
endDate: string endDate: string

17
apps/api/src/app/portfolio/portfolio.controller.ts

@ -20,6 +20,7 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -217,7 +218,8 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string @Headers('impersonation-id') impersonationId: string,
@Query('groupBy') groupBy?: string // TODO: Add type
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -229,9 +231,16 @@ export class PortfolioController {
); );
} }
let investments = await this.portfolioService.getInvestments( let investments: InvestmentItem[];
impersonationId
); if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
);
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if ( if (
impersonationId || impersonationId ||

50
apps/api/src/app/portfolio/portfolio.service.ts

@ -183,7 +183,8 @@ export class PortfolioService {
} }
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string,
groupBy?: string
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
@ -204,28 +205,39 @@ export class PortfolioService {
return []; return [];
} }
const investments = portfolioCalculator.getInvestments().map((item) => { let investments: InvestmentItem[];
return {
date: item.date,
investment: item.investment.toNumber()
};
});
// Add investment of today if (groupBy === 'month') {
const investmentOfToday = investments.filter((investment) => { investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return investment.date === format(new Date(), DATE_FORMAT); return {
}); date: item.date,
investment: item.investment.toNumber()
if (investmentOfToday.length <= 0) { };
const pastInvestments = investments.filter((investment) => { });
return isBefore(parseDate(investment.date), new Date()); } else {
investments = portfolioCalculator.getInvestments().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
}); });
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({ // Add investment of today
date: format(new Date(), DATE_FORMAT), const investmentOfToday = investments.filter((investment) => {
investment: lastInvestment?.investment ?? 0 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) => { return sortBy(investments, (investment) => {

18
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -29,7 +29,9 @@ import {
LinearScale, LinearScale,
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip Tooltip,
BarController,
BarElement
} from 'chart.js'; } from 'chart.js';
import { addDays, isAfter, parseISO, subDays } from 'date-fns'; import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@ -42,6 +44,7 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
export class InvestmentChartComponent implements OnChanges, OnDestroy { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string; @Input() currency: string;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() groupBy: string;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() locale: string; @Input() locale: string;
@ -53,6 +56,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
BarController,
BarElement,
LinearScale, LinearScale,
LineController, LineController,
LineElement, LineElement,
@ -78,7 +83,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
if (this.investments?.length > 0) { if (!this.groupBy && this.investments?.length > 0) {
// Extend chart by 5% of days in market (before) // Extend chart by 5% of days in market (before)
const firstItem = this.investments[0]; const firstItem = this.investments[0];
this.investments.unshift({ this.investments.unshift({
@ -102,13 +107,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
const data = { const data = {
labels: this.investments.map((position) => { labels: this.investments.map((investmentItem) => {
return position.date; return investmentItem.date;
}), }),
datasets: [ datasets: [
{ {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `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) => { data: this.investments.map((position) => {
return position.investment; return position.investment;
}), }),
@ -192,7 +198,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
}, },
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)], plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line' type: this.groupBy ? 'bar' : 'line'
}); });
this.isLoading = false; this.isLoading = false;

20
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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ToggleOption } from '@ghostfolio/common/types';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -22,6 +23,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentsByMonth: InvestmentItem[];
public mode;
public modeOptions: ToggleOption[] = [
{ label: 'Monthly', value: 'month' },
{ label: 'Accumulating', value: undefined }
];
public top3: Position[]; public top3: Position[];
public user: User; public user: User;
@ -55,6 +62,15 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService
.fetchInvestmentsByMonth()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ firstOrderDate, investments }) => {
this.investmentsByMonth = investments;
this.changeDetectorRef.markForCheck();
});
this.dataService this.dataService
.fetchPositions({ range: 'max' }) .fetchPositions({ range: 'max' })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -86,6 +102,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
} }
public onChangeGroupBy(aValue: string) {
this.mode = aValue;
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

26
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -2,8 +2,19 @@
<div class="investment-chart row"> <div class="investment-chart row">
<div class="col-lg"> <div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<div class="mb-3"> <div class="mb-4">
<div class="h5 mb-3" i18n>Investment Timeline</div> <div class="align-items-center d-flex mb-4">
<div class="flex-grow-1 h5 mb-0 text-truncate" i18n>
Investment Timeline
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[isLoading]="false"
[options]="modeOptions"
(change)="onChangeGroupBy($event.value)"
></gf-toggle>
</div>
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
@ -11,6 +22,17 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments" [investments]="investments"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': mode }"
></gf-investment-chart>
<gf-investment-chart
class="h-100"
groupBy="month"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investmentsByMonth"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': !mode }"
></gf-investment-chart> ></gf-investment-chart>
</div> </div>
</div> </div>

2
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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; 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 { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -14,6 +15,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
AnalysisPageRoutingModule, AnalysisPageRoutingModule,
CommonModule, CommonModule,
GfInvestmentChartModule, GfInvestmentChartModule,
GfToggleModule,
GfValueModule, GfValueModule,
MatCardModule, MatCardModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule

16
apps/client/src/app/services/data.service.ts

@ -204,6 +204,22 @@ export class DataService {
); );
} }
public fetchInvestmentsByMonth(): Observable<PortfolioInvestments> {
return this.http
.get<any>('/api/v1/portfolio/investments', {
params: { groupBy: 'month' }
})
.pipe(
map((response) => {
if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate);
}
return response;
})
);
}
public fetchSymbolItem({ public fetchSymbolItem({
dataSource, dataSource,
includeHistoricalData, includeHistoricalData,

2
libs/common/src/lib/chart-helper.ts

@ -43,7 +43,7 @@ export function getTooltipPositionerMapTop(
chart: Chart, chart: Chart,
position: TooltipPosition position: TooltipPosition
) { ) {
if (!position) { if (!position || !chart?.chartArea) {
return false; return false;
} }
return { return {

Loading…
Cancel
Save