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({ |
@Component({ |
||||
selector: 'gf-portfolio-page', |
selector: 'gf-portfolio-page', |
||||
templateUrl: './portfolio-page.html', |
templateUrl: './portfolio-page.html', |
||||
styleUrls: ['./portfolio-page.scss'] |
styleUrls: ['./portfolio-page.scss'] |
||||
}) |
}) |
||||
export class PortfolioPageComponent implements OnInit { |
export class PortfolioPageComponent implements OnDestroy, OnInit { |
||||
|
public user: User; |
||||
|
|
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
/** |
/** |
||||
* @constructor |
* @constructor |
||||
*/ |
*/ |
||||
public constructor() {} |
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private userService: UserService |
||||
|
) {} |
||||
|
|
||||
/** |
/** |
||||
* Initializes the controller |
* 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