mirror of https://github.com/ghostfolio/ghostfolio
15 changed files with 552 additions and 374 deletions
@ -0,0 +1,15 @@ |
|||
import { NgModule } from '@angular/core'; |
|||
import { RouterModule, Routes } from '@angular/router'; |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
|
|||
import { AllocationsPageComponent } from './allocations-page.component'; |
|||
|
|||
const routes: Routes = [ |
|||
{ path: '', component: AllocationsPageComponent, canActivate: [AuthGuard] } |
|||
]; |
|||
|
|||
@NgModule({ |
|||
imports: [RouterModule.forChild(routes)], |
|||
exports: [RouterModule] |
|||
}) |
|||
export class AllocationsPageRoutingModule {} |
@ -0,0 +1,221 @@ |
|||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
|||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; |
|||
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-allocations-page', |
|||
templateUrl: './allocations-page.html', |
|||
styleUrls: ['./allocations-page.scss'] |
|||
}) |
|||
export class AllocationsPageComponent implements OnDestroy, OnInit { |
|||
public accounts: { |
|||
[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 hasImpersonationId: boolean; |
|||
public period = 'current'; |
|||
public periodOptions: ToggleOption[] = [ |
|||
{ label: 'Initial', value: 'original' }, |
|||
{ label: 'Current', value: 'current' } |
|||
]; |
|||
public portfolioPositions: { [symbol: string]: PortfolioPosition }; |
|||
public positions: { [symbol: string]: any }; |
|||
public positionsArray: PortfolioPosition[]; |
|||
public sectors: { |
|||
[name: string]: { name: string; value: number }; |
|||
}; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
/** |
|||
* @constructor |
|||
*/ |
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private deviceService: DeviceDetectorService, |
|||
private impersonationStorageService: ImpersonationStorageService, |
|||
private userService: UserService |
|||
) {} |
|||
|
|||
/** |
|||
* Initializes the controller |
|||
*/ |
|||
public ngOnInit() { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.impersonationStorageService |
|||
.onChangeHasImpersonation() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((aId) => { |
|||
this.hasImpersonationId = !!aId; |
|||
}); |
|||
|
|||
this.dataService |
|||
.fetchPortfolioPositions({}) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((response = {}) => { |
|||
this.portfolioPositions = response; |
|||
this.initializeAnalysisData(this.portfolioPositions, this.period); |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public initializeAnalysisData( |
|||
aPortfolioPositions: { |
|||
[symbol: string]: PortfolioPosition; |
|||
}, |
|||
aPeriod: string |
|||
) { |
|||
this.accounts = {}; |
|||
this.continents = { |
|||
[UNKNOWN_KEY]: { |
|||
name: UNKNOWN_KEY, |
|||
value: 0 |
|||
} |
|||
}; |
|||
this.countries = { |
|||
[UNKNOWN_KEY]: { |
|||
name: UNKNOWN_KEY, |
|||
value: 0 |
|||
} |
|||
}; |
|||
this.positions = {}; |
|||
this.positionsArray = []; |
|||
this.sectors = { |
|||
[UNKNOWN_KEY]: { |
|||
name: UNKNOWN_KEY, |
|||
value: 0 |
|||
} |
|||
}; |
|||
|
|||
for (const [symbol, position] of Object.entries(aPortfolioPositions)) { |
|||
this.positions[symbol] = { |
|||
currency: position.currency, |
|||
exchange: position.exchange, |
|||
type: position.type, |
|||
value: |
|||
aPeriod === 'original' |
|||
? position.allocationInvestment |
|||
: position.allocationCurrent |
|||
}; |
|||
this.positionsArray.push(position); |
|||
|
|||
for (const [account, { current, original }] of Object.entries( |
|||
position.accounts |
|||
)) { |
|||
if (this.accounts[account]?.value) { |
|||
this.accounts[account].value += |
|||
aPeriod === 'original' ? original : current; |
|||
} else { |
|||
this.accounts[account] = { |
|||
name: account, |
|||
value: aPeriod === 'original' ? original : current |
|||
}; |
|||
} |
|||
} |
|||
|
|||
if (position.countries.length > 0) { |
|||
for (const country of position.countries) { |
|||
const { code, continent, name, weight } = country; |
|||
|
|||
if (this.continents[continent]?.value) { |
|||
this.continents[continent].value += weight * position.value; |
|||
} else { |
|||
this.continents[continent] = { |
|||
name: continent, |
|||
value: |
|||
weight * |
|||
(aPeriod === 'original' |
|||
? this.portfolioPositions[symbol].investment |
|||
: this.portfolioPositions[symbol].value) |
|||
}; |
|||
} |
|||
|
|||
if (this.countries[code]?.value) { |
|||
this.countries[code].value += weight * position.value; |
|||
} else { |
|||
this.countries[code] = { |
|||
name, |
|||
value: |
|||
weight * |
|||
(aPeriod === 'original' |
|||
? this.portfolioPositions[symbol].investment |
|||
: this.portfolioPositions[symbol].value) |
|||
}; |
|||
} |
|||
} |
|||
} else { |
|||
this.continents[UNKNOWN_KEY].value += |
|||
aPeriod === 'original' |
|||
? this.portfolioPositions[symbol].investment |
|||
: this.portfolioPositions[symbol].value; |
|||
|
|||
this.countries[UNKNOWN_KEY].value += |
|||
aPeriod === 'original' |
|||
? this.portfolioPositions[symbol].investment |
|||
: this.portfolioPositions[symbol].value; |
|||
} |
|||
|
|||
if (position.sectors.length > 0) { |
|||
for (const sector of position.sectors) { |
|||
const { name, weight } = sector; |
|||
|
|||
if (this.sectors[name]?.value) { |
|||
this.sectors[name].value += weight * position.value; |
|||
} else { |
|||
this.sectors[name] = { |
|||
name, |
|||
value: |
|||
weight * |
|||
(aPeriod === 'original' |
|||
? this.portfolioPositions[symbol].investment |
|||
: this.portfolioPositions[symbol].value) |
|||
}; |
|||
} |
|||
} |
|||
} else { |
|||
this.sectors[UNKNOWN_KEY].value += |
|||
aPeriod === 'original' |
|||
? this.portfolioPositions[symbol].investment |
|||
: this.portfolioPositions[symbol].value; |
|||
} |
|||
} |
|||
} |
|||
|
|||
public onChangePeriod(aValue: string) { |
|||
this.period = aValue; |
|||
|
|||
this.initializeAnalysisData(this.portfolioPositions, this.period); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
} |
@ -0,0 +1,193 @@ |
|||
<div class="container"> |
|||
<div class="mb-5 row"> |
|||
<div class="col"> |
|||
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3> |
|||
<gf-positions-table |
|||
class="mb-4" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="positionsArray" |
|||
></gf-positions-table> |
|||
</div> |
|||
</div> |
|||
<div class="proportion-charts row"> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Type</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="type" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="true" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="positions" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Account</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="name" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="hasImpersonationId" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="accounts" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Currency</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="currency" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="true" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="positions" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Exchange</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="exchange" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="true" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="positions" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Sector</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="name" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="false" |
|||
[locale]="user?.settings?.locale" |
|||
[maxItems]="10" |
|||
[positions]="sectors" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Continent</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="name" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="false" |
|||
[locale]="user?.settings?.locale" |
|||
[positions]="continents" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>By Country</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-portfolio-proportion-chart |
|||
key="name" |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[isInPercent]="false" |
|||
[locale]="user?.settings?.locale" |
|||
[maxItems]="10" |
|||
[positions]="countries" |
|||
></gf-portfolio-proportion-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
<div class="row world-map-chart"> |
|||
<div class="col-lg"> |
|||
<mat-card class="mb-3"> |
|||
<mat-card-header class="w-100"> |
|||
<mat-card-title i18n>Regions</mat-card-title> |
|||
<gf-toggle |
|||
[defaultValue]="period" |
|||
[isLoading]="false" |
|||
[options]="periodOptions" |
|||
(change)="onChangePeriod($event.value)" |
|||
></gf-toggle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<gf-world-map-chart |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[countries]="countries" |
|||
></gf-world-map-chart> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
</div> |
@ -0,0 +1,27 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module'; |
|||
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; |
|||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; |
|||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; |
|||
|
|||
import { AllocationsPageRoutingModule } from './allocations-page-routing.module'; |
|||
import { AllocationsPageComponent } from './allocations-page.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [AllocationsPageComponent], |
|||
exports: [], |
|||
imports: [ |
|||
AllocationsPageRoutingModule, |
|||
CommonModule, |
|||
GfPositionsTableModule, |
|||
GfToggleModule, |
|||
GfWorldMapChartModule, |
|||
MatCardModule, |
|||
PortfolioProportionChartModule |
|||
], |
|||
providers: [], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class AllocationsPageModule {} |
@ -0,0 +1,40 @@ |
|||
:host { |
|||
.proportion-charts { |
|||
.mat-card { |
|||
.mat-card-content { |
|||
padding: 1rem 2rem; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.world-map-chart { |
|||
.mat-card { |
|||
.mat-card-content { |
|||
aspect-ratio: 16 / 9; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.mat-card { |
|||
.mat-card-header { |
|||
::ng-deep { |
|||
.mat-card-header-text { |
|||
flex: 1 1 auto; |
|||
} |
|||
} |
|||
|
|||
gf-toggle { |
|||
font-size: 90%; |
|||
} |
|||
} |
|||
|
|||
a { |
|||
color: rgba(var(--palette-primary-500), 1); |
|||
font-weight: 500; |
|||
|
|||
&:hover { |
|||
color: rgba(var(--palette-primary-300), 1); |
|||
} |
|||
} |
|||
} |
|||
} |
@ -1,18 +1,44 @@ |
|||
import { Component, OnInit } from '@angular/core'; |
|||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { User } from '@ghostfolio/common/interfaces'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-portfolio-page', |
|||
templateUrl: './portfolio-page.html', |
|||
styleUrls: ['./portfolio-page.scss'] |
|||
}) |
|||
export class PortfolioPageComponent implements OnInit { |
|||
export class PortfolioPageComponent implements OnDestroy, OnInit { |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
/** |
|||
* @constructor |
|||
*/ |
|||
public constructor() {} |
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private userService: UserService |
|||
) {} |
|||
|
|||
/** |
|||
* Initializes the controller |
|||
*/ |
|||
public ngOnInit() {} |
|||
public ngOnInit() { |
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
} |
|||
|
Loading…
Reference in new issue