Feature/improve analysis page (#609)
* Improve analysis page (show y-axis, extend chart in relation to days in market) * Update changelog
This commit is contained in:
parent
0179823ad9
commit
e54638a684
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
|
||||||
- Restructured the about page
|
- Restructured the about page
|
||||||
- Start refactoring _transactions_ to _activities_
|
- Start refactoring _transactions_ to _activities_
|
||||||
- Refactored the demo user id
|
- Refactored the demo user id
|
||||||
|
@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
|
|||||||
import {
|
import {
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -48,42 +48,6 @@ export class PortfolioController {
|
|||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('investments')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async findAll(
|
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
|
||||||
@Res() res: Response
|
|
||||||
): Promise<InvestmentItem[]> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
|
||||||
return <any>res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
|
||||||
impersonationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
const maxInvestment = investments.reduce(
|
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
investments = investments.map((item) => ({
|
|
||||||
date: item.date,
|
|
||||||
investment: item.investment / maxInvestment
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <any>res.json(investments);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@ -200,6 +164,42 @@ export class PortfolioController {
|
|||||||
return <any>res.json({ accounts, hasError, holdings });
|
return <any>res.json({ accounts, hasError, holdings });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('investments')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getInvestments(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioInvestments> {
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
res.status(StatusCodes.FORBIDDEN);
|
||||||
|
return <any>res.json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
let investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxInvestment = investments.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxInvestment
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPerformance(
|
public async getPerformance(
|
||||||
|
@ -55,7 +55,7 @@ import {
|
|||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty, sortBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
@ -150,12 +150,33 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioCalculator.getInvestments().map((item) => {
|
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart(
|
public async getChart(
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
@ -19,7 +20,7 @@ import {
|
|||||||
PointElement,
|
PointElement,
|
||||||
TimeScale
|
TimeScale
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-investment-chart',
|
selector: 'gf-investment-chart',
|
||||||
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
|||||||
templateUrl: './investment-chart.component.html',
|
templateUrl: './investment-chart.component.html',
|
||||||
styleUrls: ['./investment-chart.component.scss']
|
styleUrls: ['./investment-chart.component.scss']
|
||||||
})
|
})
|
||||||
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||||
|
@Input() daysInMarket: number;
|
||||||
@Input() investments: InvestmentItem[];
|
@Input() investments: InvestmentItem[];
|
||||||
|
@Input() isInPercent = false;
|
||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas;
|
@ViewChild('chartCanvas') chartCanvas;
|
||||||
|
|
||||||
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {}
|
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
if (this.investments) {
|
if (this.investments) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
if (this.investments?.length > 0) {
|
if (this.investments?.length > 0) {
|
||||||
// Extend chart by three months (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({
|
||||||
...firstItem,
|
...firstItem,
|
||||||
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
|
date: subDays(
|
||||||
|
parseISO(firstItem.date),
|
||||||
|
this.daysInMarket * 0.05 || 90
|
||||||
|
).toISOString(),
|
||||||
investment: 0
|
investment: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extend chart by three months (after)
|
// Extend chart by 5% of days in market (after)
|
||||||
const lastItem = this.investments[this.investments.length - 1];
|
const lastItem = this.investments[this.investments.length - 1];
|
||||||
this.investments.push({
|
this.investments.push({
|
||||||
...lastItem,
|
...lastItem,
|
||||||
date: addMonths(new Date(), 3).toISOString()
|
date: addDays(
|
||||||
|
parseDate(lastItem.date),
|
||||||
|
this.daysInMarket * 0.05 || 90
|
||||||
|
).toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,12 +143,26 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: false,
|
display: !this.isInPercent,
|
||||||
grid: {
|
grid: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
display: false
|
display: true,
|
||||||
|
callback: (tickValue, index, ticks) => {
|
||||||
|
if (index === 0 || index === ticks.length - 1) {
|
||||||
|
// Only print last and first legend entry
|
||||||
|
if (typeof tickValue === 'number') {
|
||||||
|
return tickValue.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tickValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
mirror: true,
|
||||||
|
z: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [InvestmentChartComponent],
|
declarations: [InvestmentChartComponent],
|
||||||
exports: [InvestmentChartComponent],
|
exports: [InvestmentChartComponent],
|
||||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
imports: [CommonModule, NgxSkeletonLoaderModule]
|
||||||
providers: []
|
|
||||||
})
|
})
|
||||||
export class GfInvestmentChartModule {}
|
export class GfInvestmentChartModule {}
|
||||||
|
@ -2,9 +2,9 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
import { 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 { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -16,28 +16,10 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './analysis-page.html'
|
templateUrl: './analysis-page.html'
|
||||||
})
|
})
|
||||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||||
public accounts: {
|
public daysInMarket: number;
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
|
|
||||||
};
|
|
||||||
public continents: {
|
|
||||||
[code: string]: { name: string; value: number };
|
|
||||||
};
|
|
||||||
public countries: {
|
|
||||||
[code: string]: { name: string; value: number };
|
|
||||||
};
|
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public period = 'current';
|
|
||||||
public periodOptions: ToggleOption[] = [
|
|
||||||
{ label: 'Initial', value: 'original' },
|
|
||||||
{ label: 'Current', value: 'current' }
|
|
||||||
];
|
|
||||||
public investments: InvestmentItem[];
|
public investments: InvestmentItem[];
|
||||||
public portfolioPositions: { [symbol: string]: PortfolioPosition };
|
|
||||||
public positions: { [symbol: string]: any };
|
|
||||||
public sectors: {
|
|
||||||
[name: string]: { name: string; value: number };
|
|
||||||
};
|
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -69,8 +51,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
this.dataService
|
this.dataService
|
||||||
.fetchInvestments()
|
.fetchInvestments()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((response) => {
|
.subscribe(({ firstOrderDate, investments }) => {
|
||||||
this.investments = response;
|
this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
|
this.investments = investments;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,8 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-investment-chart
|
<gf-investment-chart
|
||||||
class="h-100"
|
class="h-100"
|
||||||
|
[daysInMarket]="daysInMarket"
|
||||||
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
[investments]="investments"
|
[investments]="investments"
|
||||||
></gf-investment-chart>
|
></gf-investment-chart>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
@ -5,7 +5,6 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
|
|||||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||||
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
|
||||||
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
|
||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
|
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
|
||||||
@ -23,13 +22,13 @@ import {
|
|||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
User
|
User
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
@ -124,6 +123,18 @@ export class DataService {
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchInvestments(): Observable<PortfolioInvestments> {
|
||||||
|
return this.http.get<any>('/api/portfolio/investments').pipe(
|
||||||
|
map((response) => {
|
||||||
|
if (response.firstOrderDate) {
|
||||||
|
response.firstOrderDate = parseISO(response.firstOrderDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public fetchSymbolItem({
|
public fetchSymbolItem({
|
||||||
dataSource,
|
dataSource,
|
||||||
includeHistoricalData = false,
|
includeHistoricalData = false,
|
||||||
@ -170,10 +181,6 @@ export class DataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public fetchInvestments() {
|
|
||||||
return this.http.get<InvestmentItem[]>('/api/portfolio/investments');
|
|
||||||
}
|
|
||||||
|
|
||||||
public fetchPortfolioDetails(aParams: { [param: string]: any }) {
|
public fetchPortfolioDetails(aParams: { [param: string]: any }) {
|
||||||
return this.http.get<PortfolioDetails>('/api/portfolio/details', {
|
return this.http.get<PortfolioDetails>('/api/portfolio/details', {
|
||||||
params: aParams
|
params: aParams
|
||||||
|
@ -8,6 +8,7 @@ import { Export } from './export.interface';
|
|||||||
import { InfoItem } from './info-item.interface';
|
import { InfoItem } from './info-item.interface';
|
||||||
import { PortfolioChart } from './portfolio-chart.interface';
|
import { PortfolioChart } from './portfolio-chart.interface';
|
||||||
import { PortfolioDetails } from './portfolio-details.interface';
|
import { PortfolioDetails } from './portfolio-details.interface';
|
||||||
|
import { PortfolioInvestments } from './portfolio-investments.interface';
|
||||||
import { PortfolioItem } from './portfolio-item.interface';
|
import { PortfolioItem } from './portfolio-item.interface';
|
||||||
import { PortfolioOverview } from './portfolio-overview.interface';
|
import { PortfolioOverview } from './portfolio-overview.interface';
|
||||||
import { PortfolioPerformance } from './portfolio-performance.interface';
|
import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||||
@ -33,6 +34,7 @@ export {
|
|||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioItem,
|
PortfolioItem,
|
||||||
PortfolioOverview,
|
PortfolioOverview,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
import { InvestmentItem } from './investment-item.interface';
|
||||||
|
|
||||||
|
export interface PortfolioInvestments {
|
||||||
|
firstOrderDate: Date;
|
||||||
|
investments: InvestmentItem[];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user