Browse Source

Add total amount chart

pull/1344/head
Thomas 3 years ago
parent
commit
4b57c6f539
  1. 23
      apps/api/src/app/portfolio/portfolio.controller.ts
  2. 116
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 4
      apps/client/src/app/components/home-market/home-market.component.ts
  4. 2
      apps/client/src/app/components/home-market/home-market.html
  5. 8
      apps/client/src/app/components/home-overview/home-overview.component.ts
  6. 75
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  7. 27
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  8. 5
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  9. 24
      apps/client/src/app/services/data.service.ts
  10. 2
      libs/common/src/lib/interfaces/historical-data-item.interface.ts

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

@ -68,7 +68,7 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange, @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false; let hasError = false;
@ -88,9 +88,9 @@ export class PortfolioController {
summary, summary,
totalValueInBaseCurrency totalValueInBaseCurrency
} = await this.portfolioService.getDetails({ } = await this.portfolioService.getDetails({
dateRange,
filters, filters,
impersonationId, impersonationId,
dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -183,6 +183,7 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy @Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
if ( if (
@ -198,12 +199,16 @@ export class PortfolioController {
let investments: InvestmentItem[]; let investments: InvestmentItem[];
if (groupBy === 'month') { if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments( investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId, impersonationId,
'month' groupBy: 'month'
); });
} else { } else {
investments = await this.portfolioService.getInvestments(impersonationId); investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId
});
} }
if ( if (
@ -230,7 +235,7 @@ export class PortfolioController {
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') dateRange @Query('range') dateRange: DateRange = 'max'
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformanceV2( const performanceInformation = await this.portfolioService.getPerformanceV2(
{ {
@ -258,11 +263,11 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions( public async getPositions(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') dateRange: DateRange = 'max'
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions( const result = await this.portfolioService.getPositions(
impersonationId, impersonationId,
range dateRange
); );
if ( if (

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

@ -207,11 +207,16 @@ export class PortfolioService {
}; };
} }
public async getInvestments( public async getInvestments({
aImpersonationId: string, dateRange,
groupBy?: GroupBy impersonationId,
): Promise<InvestmentItem[]> { groupBy
const userId = await this.getUserId(aImpersonationId, this.request.user.id); }: {
dateRange: DateRange;
impersonationId: string;
groupBy?: GroupBy;
}): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -283,98 +288,18 @@ export class PortfolioService {
} }
} }
return sortBy(investments, (investment) => { investments = sortBy(investments, (investment) => {
return investment.date; return investment.date;
}); });
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); const startDate = this.getStartDate(
if (transactionPoints.length === 0) { dateRange,
return { parseDate(investments[0]?.date)
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
let portfolioStart = parse(
transactionPoints[0].date,
DATE_FORMAT,
new Date()
);
// Get start date for the full portfolio because of because of the
// min and max calculation
portfolioStart = this.getStartDate('max', portfolioStart);
const timelineSpecification: TimelineSpecification[] = [
{
start: format(portfolioStart, DATE_FORMAT),
accuracy: 'day'
}
];
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
format(new Date(), DATE_FORMAT)
);
const timeline = timelineInfo.timelinePeriods;
const items = timeline
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
value: timelineItem.netPerformance.toNumber()
}));
let lastItem = null;
if (timeline.length > 0) {
lastItem = timeline[timeline.length - 1];
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance ?? 0
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance ?? 0
);
if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false;
isAllTimeLow = false;
}
portfolioStart = startOfDay(
this.getStartDate(
aDateRange,
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
)
); );
return { return investments.filter(({ date }) => {
isAllTimeHigh, return !isBefore(parseDate(date), startDate);
isAllTimeLow, });
items: items.filter((item) => {
// Filter items of date range
return !isAfter(portfolioStart, parseDate(item.date));
})
};
} }
public async getChartV2({ public async getChartV2({
@ -441,7 +366,7 @@ export class PortfolioService {
filters?: Filter[]; filters?: Filter[];
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
// TODO: // TODO
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1035,10 +960,11 @@ export class PortfolioService {
return { return {
chart: historicalDataContainer.items.map( chart: historicalDataContainer.items.map(
({ date, netPerformanceInPercentage }) => { ({ date, netPerformance, netPerformanceInPercentage }) => {
return { return {
date, date,
value: netPerformanceInPercentage netPerformance,
netPerformanceInPercentage
}; };
} }
), ),

4
apps/client/src/app/components/home-market/home-market.component.ts

@ -24,7 +24,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public fearLabel = $localize`Fear`; public fearLabel = $localize`Fear`;
public greedLabel = $localize`Greed`; public greedLabel = $localize`Greed`;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[]; public historicalDataItems: HistoricalDataItem[];
public info: InfoItem; public info: InfoItem;
public isLoading = true; public isLoading = true;
public readonly numberOfDays = 180; public readonly numberOfDays = 180;
@ -67,7 +67,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ historicalData, marketPrice }) => { .subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.historicalData = [ this.historicalDataItems = [
...historicalData, ...historicalData,
{ {
date: resetHours(new Date()).toISOString(), date: resetHours(new Date()).toISOString(),

2
apps/client/src/app/components/home-market/home-market.html

@ -10,7 +10,7 @@
symbol="Fear & Greed Index" symbol="Fear & Greed Index"
yMax="100" yMax="100"
yMin="0" yMin="0"
[historicalDataItems]="historicalData" [historicalDataItems]="historicalDataItems"
[isAnimated]="true" [isAnimated]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showXAxis]="true" [showXAxis]="true"

8
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -116,12 +116,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.performance = response.performance; this.performance = response.performance;
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.historicalDataItems = response.chart.map(({ date, value }) => { this.historicalDataItems = response.chart.map(
({ date, netPerformanceInPercentage }) => {
return { return {
date, date,
value value: netPerformanceInPercentage
}; };
}); }
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

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

@ -15,6 +15,7 @@ import {
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT,
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getTextColor, getTextColor,
@ -22,7 +23,7 @@ import {
transformTickToAbbreviation transformTickToAbbreviation
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy } from '@ghostfolio/common/types'; import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { import {
BarController, BarController,
BarElement, BarElement,
@ -35,7 +36,15 @@ import {
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns'; import {
addDays,
format,
isAfter,
isBefore,
parseISO,
subDays
} from 'date-fns';
import { LineChartItem } from '@ghostfolio/common/interfaces';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -44,17 +53,19 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
styleUrls: ['./investment-chart.component.scss'] styleUrls: ['./investment-chart.component.scss']
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() currency: string; @Input() currency: string;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() groupBy: GroupBy; @Input() groupBy: GroupBy;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() locale: string; @Input() locale: string;
@Input() range: DateRange = 'max';
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
public chart: Chart; public chart: Chart<any>;
public isLoading = true; public isLoading = true;
private data: InvestmentItem[]; private data: InvestmentItem[];
@ -77,7 +88,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
public ngOnChanges() { public ngOnChanges() {
if (this.investments) { if (this.benchmarkDataItems && this.investments) {
this.initialize(); this.initialize();
} }
} }
@ -93,41 +104,61 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.data = this.investments.map((a) => Object.assign({}, a)); this.data = this.investments.map((a) => Object.assign({}, a));
if (!this.groupBy && this.data?.length > 0) { if (!this.groupBy && this.data?.length > 0) {
if (this.range === 'max') {
// Extend chart by 5% of days in market (before) // Extend chart by 5% of days in market (before)
const firstItem = this.data[0]; const firstItem = this.data[0];
this.data.unshift({ this.data.unshift({
...firstItem, ...firstItem,
date: subDays( date: format(
parseISO(firstItem.date), subDays(parseISO(firstItem.date), this.daysInMarket * 0.05 || 90),
this.daysInMarket * 0.05 || 90 DATE_FORMAT
).toISOString(), ),
investment: 0 investment: 0
}); });
}
// Extend chart by 5% of days in market (after) // Extend chart by 5% of days in market (after)
const lastItem = this.data[this.data.length - 1]; const lastItem = this.data[this.data.length - 1];
this.data.push({ this.data.push({
...lastItem, ...lastItem,
date: addDays( date: format(
parseDate(lastItem.date), addDays(parseDate(lastItem.date), this.daysInMarket * 0.05 || 90),
this.daysInMarket * 0.05 || 90 DATE_FORMAT
).toISOString() )
});
}
let currentIndex = 0;
const totalAmountDataItems: { x: Date; y: number }[] = [];
for (const { date, value } of this.benchmarkDataItems) {
// TODO: Improve total amount calculation
if (
isBefore(parseDate(this.data?.[currentIndex]?.date), parseDate(date))
) {
currentIndex += 1;
}
totalAmountDataItems.push({
x: parseDate(date),
y: this.data?.[currentIndex]?.investment + value
}); });
} }
const data = { const data = {
labels: this.data.map((investmentItem) => { labels: this.benchmarkDataItems.map(({ date }) => {
return investmentItem.date; return date;
}), }),
datasets: [ datasets: [
{ {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, 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: this.groupBy ? 0 : 2, borderWidth: this.groupBy ? 0 : 2,
data: this.data.map((position) => { data: this.data.map(({ date, investment }) => {
return this.isInPercent return {
? position.investment * 100 x: parseDate(date),
: position.investment; y: this.isInPercent ? investment * 100 : investment
};
}), }),
label: $localize`Deposit`, label: $localize`Deposit`,
segment: { segment: {
@ -139,6 +170,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
borderDash: (context: unknown) => this.isInFuture(context, [2, 2]) borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
}, },
stepped: true stepped: true
},
{
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 1,
data: totalAmountDataItems,
fill: false,
label: $localize`Total Amount`,
pointRadius: 0
} }
] ]
}; };

27
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -39,6 +39,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
{ label: $localize`Accumulating`, value: undefined } { label: $localize`Accumulating`, value: undefined }
]; ];
public performanceDataItems: HistoricalDataItem[]; public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public top3: Position[]; public top3: Position[];
public user: User; public user: User;
@ -131,7 +132,24 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => { .subscribe(({ chart }) => {
this.firstOrderDate = new Date(chart?.[0]?.date ?? new Date()); this.firstOrderDate = new Date(chart?.[0]?.date ?? new Date());
this.performanceDataItems = chart;
this.performanceDataItems = [];
this.performanceDataItemsInPercentage = [];
for (const {
date,
netPerformance,
netPerformanceInPercentage
} of chart) {
this.performanceDataItems.push({
date,
value: netPerformance
});
this.performanceDataItemsInPercentage.push({
date,
value: netPerformanceInPercentage
});
}
this.updateBenchmarkDataItems(); this.updateBenchmarkDataItems();
@ -139,7 +157,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
this.dataService this.dataService
.fetchInvestments() .fetchInvestments({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ firstOrderDate, investments }) => { .subscribe(({ firstOrderDate, investments }) => {
this.daysInMarket = differenceInDays(new Date(), firstOrderDate); this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -149,7 +167,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
this.dataService this.dataService
.fetchInvestmentsByMonth() .fetchInvestments({
groupBy: 'month',
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ investments }) => { .subscribe(({ investments }) => {
this.investmentsByMonth = investments; this.investmentsByMonth = investments;

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

@ -10,7 +10,7 @@
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[isLoading]="isLoadingBenchmarkComparator" [isLoading]="isLoadingBenchmarkComparator"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItems" [performanceDataItems]="performanceDataItemsInPercentage"
[user]="user" [user]="user"
(benchmarkChanged)="onChangeBenchmark($event)" (benchmarkChanged)="onChangeBenchmark($event)"
(dateRangeChanged)="onChangeDateRange($event)" (dateRangeChanged)="onChangeDateRange($event)"
@ -119,12 +119,14 @@
<div class="chart-container"> <div class="chart-container">
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
[benchmarkDataItems]="performanceDataItems"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[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 }" [ngClass]="{ 'd-none': mode }"
[range]="user?.settings?.dateRange"
></gf-investment-chart> ></gf-investment-chart>
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
@ -135,6 +137,7 @@
[investments]="investmentsByMonth" [investments]="investmentsByMonth"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': !mode }" [ngClass]="{ 'd-none': !mode }"
[range]="user?.settings?.dateRange"
[savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate" [savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate"
></gf-investment-chart> ></gf-investment-chart>
</div> </div>

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

@ -163,23 +163,15 @@ export class DataService {
return info; return info;
} }
public fetchInvestments(): Observable<PortfolioInvestments> { public fetchInvestments({
return this.http.get<any>('/api/v1/portfolio/investments').pipe( groupBy,
map((response) => { range
if (response.firstOrderDate) { }: {
response.firstOrderDate = parseISO(response.firstOrderDate); groupBy?: 'month';
} range: DateRange;
}): Observable<PortfolioInvestments> {
return response;
})
);
}
public fetchInvestmentsByMonth(): Observable<PortfolioInvestments> {
return this.http return this.http
.get<any>('/api/v1/portfolio/investments', { .get<any>('/api/v1/portfolio/investments', { params: { groupBy, range } })
params: { groupBy: 'month' }
})
.pipe( .pipe(
map((response) => { map((response) => {
if (response.firstOrderDate) { if (response.firstOrderDate) {

2
libs/common/src/lib/interfaces/historical-data-item.interface.ts

@ -4,5 +4,5 @@ export interface HistoricalDataItem {
grossPerformancePercent?: number; grossPerformancePercent?: number;
netPerformance?: number; netPerformance?: number;
netPerformanceInPercentage?: number; netPerformanceInPercentage?: number;
value: number; value?: number;
} }

Loading…
Cancel
Save