Compare commits

...

54 Commits

Author SHA1 Message Date
81e83d4cea Release 1.51.0 (#362) 2021-09-11 11:25:07 +02:00
5d4156ecec Feature/refactor position detail dialog (#355)
* Add name to portfolio position endpoint

* Update changelog
2021-09-11 11:23:47 +02:00
4693a8baa2 Release 1.50.0 (#361) 2021-09-11 11:21:53 +02:00
773444b1e2 Bugfix/fix home button overlap on ios (#360)
* Fix overlap

* Update changelog
2021-09-11 11:17:49 +02:00
3c46bde8d5 Bugfix/fix fear and greed index (#359)
* Fix fear and greed index
* Refactor fear and greed index symbol
   * GF.FEAR_AND_GREED_INDEX -> _GF_FEAR_AND_GREED_INDEX

* Update changelog
2021-09-11 11:14:55 +02:00
63ee33b685 Use 'import type' to import types, eliminate webpack warnings (#358) 2021-09-11 09:27:22 +02:00
bc87c0a3e1 Add slack (#357) 2021-09-10 18:11:35 +02:00
caa9fc3efa Release 1.49.0 (#354) 2021-09-08 22:19:53 +02:00
9ed82ac82b Feature/improve labels of allocation chart by symbol (#353)
* Improve labels

* Update changelog
2021-09-08 22:03:33 +02:00
9c9ca4ab1e Add labels to allocation piecharts (#337) 2021-09-08 21:31:34 +02:00
b0b0942162 Release 1.48.0 (#352)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:23:07 +02:00
9cbf789c22 Bugfix/fix values in position detail dialog (#351)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:11:38 +02:00
ee5ab05d8a Release 1.47.1 (#350) 2021-09-06 22:55:08 +02:00
20731c67cb Release 1.47.0 (#349) 2021-09-06 22:34:17 +02:00
bf8856ad19 Bugfix/fix search for cryptocurrencies (#348)
* Fix the search for cryptocurrency symbols

* Update changelog
2021-09-06 22:02:49 +02:00
a31d79821d Release 1.46.0 (#347) 2021-09-05 22:15:21 +02:00
48ab862bb6 net performance for current positions (#330)
* implement fees for transaction points #324

* add net performance to current positions #324

* add net performance to calculate timeline #324

* make timeline fee accumulated by default #324

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-05 21:21:22 +02:00
ba234a470e Feature/add storybook story for trend indicator component (#346)
* Add storybook story for trend indicator component

* Update changelog
2021-09-05 08:49:06 +02:00
ccae660104 Feature/add storybook story for no transactions info component (#345)
* Add storybook story for no transactions info component

* Update changelog
2021-09-05 08:34:02 +02:00
21ed91d184 Feature/add storybook story for logo component (#344)
* Add storybook story for logo component

* Update changelog
2021-09-05 08:03:32 +02:00
5fd413e57e Feature/setup storybook (#332)
* Setup ui library with storybook

* Add value component with story

* Update changelog
2021-09-04 22:12:54 +02:00
4c194c938a Feature/add contributors count to statistics (#342)
* Add contributors count to statistics

* Update changelog
2021-09-04 19:46:24 +02:00
a4d049e53d Release 1.45.0 (#340) 2021-09-04 19:20:09 +02:00
f9c4408126 Update yarn start:server to watch for changes (#338) 2021-09-04 19:05:04 +02:00
d046f1d498 Feature/upgrade nx to version 12.8.0 (#331)
* Upgrade angular and Nx

* Update changelog
2021-09-04 11:25:40 +02:00
ad96d6e53e Feature/upgrade prisma from version 2.24.1 to 2.30.2 (#325)
* Upgrade prisma

* Update changelog
2021-09-04 10:49:09 +02:00
747e5b63fa Feature/restructure allocations page including allocations by symbol (#333)
* Restructure allocations page

* Update changelog
2021-09-03 23:20:30 +02:00
b1187cf880 Add a new symbol allocation chart (#326)
* Add a new symbol allocation chart

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-03 17:48:35 +02:00
ba9e6eab58 Feature/add link to transactions below holdings (#329)
* Add link: Manage transactions

* Update changelog
2021-09-02 21:17:01 +02:00
01feead017 Show decimal transactionCount and singular for 1 transaction (#327)
* Show decimal `transactionCount` and singular for 1 transaction

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-02 20:47:27 +02:00
6a0cfb8f77 Release 1.44.0 (#323) 2021-08-30 18:22:41 +02:00
6386786ac0 Bugfix/improve symbol lookup (#322)
* Improve symbol lookup

* Update changelog
2021-08-30 18:08:21 +02:00
d3be6577c8 Add feature: time-weighted rate of return (TWR) (#321) 2021-08-29 17:09:11 +02:00
73a967a7e5 Feature/add cash as asset sub class (#319)
* Add cash as asset sub class

* Update changelog
2021-08-26 21:49:02 +02:00
836ff6ec13 Feature/upgrade svgmap to version 2.6.0 (#318)
* Upgrade svgmap

* Update changelog
2021-08-26 21:23:49 +02:00
c5bb3023d3 Bugfix/filter out positions without quantity (#317)
* Filter out positions without any quantity

* Update changelog
2021-08-26 17:45:04 +02:00
695c378b48 Release 1.43.0 (#316) 2021-08-24 21:31:19 +02:00
fe975945d1 Feature/add fallback for loading currencies (#315)
* Add fallback for loading currencies

* Update changelog
2021-08-24 21:09:02 +02:00
d8782b0d4c Feature/automate countries for stocks in symbol profile data (#314)
* Automate countries for stocks in symbol profile data

* Update changelog
2021-08-24 20:24:18 +02:00
e14f08a8fb Release 1.42.0 (#313) 2021-08-22 22:37:44 +02:00
72c065a59d Feature/introduce asset sub class (#312)
* Introduce asset sub class

* Update changelog
2021-08-22 22:19:10 +02:00
98dac4052a Feature/add subscription type to the admin user table (#311)
* Add the subscription type to the user table in the admin control panel

* Update changelog
2021-08-22 22:11:05 +02:00
2083d28d02 Feature/minor improvements in the page components (#310)
* Move permissions to constructor

* Sort imports
2021-08-22 10:25:34 +02:00
addd5c36d9 Release 1.41.0 (#309) 2021-08-21 15:35:59 +02:00
aad8f77093 Feature/improve allocations by account (#308)
* Improve allocations by account

* Eliminate accounts from PortfolioPosition

* Ignore cash assets in the allocation chart by sector, continent and country

* Add missing accounts to portfolio details

* Update changelog
2021-08-21 15:03:55 +02:00
a904208d06 Feature/improve table styling (#307)
* Improve table styling

* Update changelog
2021-08-21 14:56:50 +02:00
2733b78044 Minor improvement (#306) 2021-08-21 14:56:11 +02:00
b43b515df1 Feature/add link to system status page (#305)
* Add link to system status page

* Update changelog
2021-08-21 08:57:12 +02:00
70e14b4d3c Feature/improve restricted view mode (#304)
* Improve wording and padding

* Update changelog
2021-08-20 20:58:33 +02:00
0f7d1b7d59 Release 1.40.0 (#303) 2021-08-19 21:56:21 +02:00
c2ab6a6c44 Feature/improve portfolio details endpoint (#302)
* Make details endpoint fault tolerant (do not throw error)

* Update changelog
2021-08-19 21:44:10 +02:00
c71a4c078e Bugfix/convert g bp to gbp in yahoo finance service (#301)
* Fix currency inconsistency in the yahoo finance service (GBp to GBP)

* Update changelog
2021-08-18 18:22:01 +02:00
e17b217032 Bugfix/fix issue on buy date in position detail dialog (#297)
* Fix issue on buy date

* Update changelog
2021-08-17 21:31:32 +02:00
408e08d43c Bugfix/fix node engine version mismatch (#299)
* Fix node engine version mismatch

* Update changelog
2021-08-17 20:32:12 +02:00
159 changed files with 9379 additions and 3790 deletions

11
.storybook/main.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};

10
.storybook/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"exclude": [
"../**/*.spec.js",
"../**/*.spec.ts",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../**/*"]
}

View File

@ -5,6 +5,135 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.51.0 - 11.09.2021
### Changed
- Provided the name in the portfolio position endpoint
## 1.50.0 - 11.09.2021
### Fixed
- Fixed the _Fear & Greed Index_ (market mood)
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
## 1.49.0 - 08.09.2021
### Added
- Added labels to the allocation chart by symbol on desktop
## 1.48.0 - 07.09.2021
### Added
- Added the attribute `precision` in the value component
### Fixed
- Hid the performance in the _Presenter View_
## 1.47.1 - 06.09.2021
### Fixed
- Fixed the search functionality for cryptocurrency symbols
## 1.46.0 - 05.09.2021
### Added
- Extended the statistics section on the about page by the _GitHub_ contributors count
- Set up _Storybook_
- Added a story for the logo component
- Added a story for the no transactions info component
- Added a story for the trend indicator component
- Added a story for the value component
### Changed
- Switched from gross to net performance
- Restructured the portfolio summary tab on the home page (fees and net performance)
## 1.45.0 - 04.09.2021
### Added
- Added a link below the holdings to manage the transactions
- Added the allocation chart by symbol
### Changed
- Restructured the allocations page
- Upgraded `angular` from version `12.0.4` to `12.2.4`
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
### Fixed
- Fixed the value formatting for integers (transactions count)
## 1.44.0 - 30.08.2021
### Changed
- Extended the sub classification of assets by cash
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
### Fixed
- Filtered out positions without any quantity in the positions table
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
## 1.43.0 - 24.08.2021
### Added
- Extended the data management of symbol profile data by countries (automated for stocks)
- Added a fallback for initially loading currencies if historical data is not yet available
## 1.42.0 - 22.08.2021
### Added
- Added the subscription type to the users table of the admin control panel
- Introduced the sub classification of assets
### Todo
- Apply data migration (`yarn database:push`)
## 1.41.0 - 21.08.2021
### Added
- Added a link to the system status page
### Changed
- Improved the wording for the _Restricted View_: _Presenter View_
- Improved the styling of the tables
- Ignored cash assets in the allocation chart by sector, continent and country
### Fixed
- Fixed an issue in the allocation chart by account (wrong calculation)
- Fixed an issue in the allocation chart by account (missing cash accounts)
## 1.40.0 - 19.08.2021
### Changed
- Improved the fault tolerance of the portfolio details endpoint
### Fixed
- Fixed the node engine version mismatch in `package.json`
- Fixed an issue on the buy date in the position detail dialog
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `GBp` to `GBP`)
## 1.39.0 - 16.08.2021
### Added

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode
@ -116,6 +116,10 @@ Please make sure you have completed the instructions from [_Setup_](#Setup).
Run `yarn start:client`
### Start _Storybook_
Run `yarn start:storybook`
## Testing
Run `yarn test`

View File

@ -6,13 +6,16 @@
"defaultProject": "api",
"schematics": {
"@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest",
"e2eTestRunner": "cypress"
},
"@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nrwl/nest": {}
"@nrwl/nest": {},
"@nrwl/angular:component": {}
},
"projects": {
"api": {
@ -239,6 +242,90 @@
}
}
}
},
"ui": {
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
},
"ui-e2e": {
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
}
}
}
}

View File

@ -1,5 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -6,7 +6,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -5,7 +5,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -14,9 +15,11 @@ import { AdminService } from './admin.service';
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule
PrismaModule,
SubscriptionModule
],
controllers: [AdminController],
providers: [AdminService]
providers: [AdminService],
exports: [AdminService]
})
export class AdminModule {}

View File

@ -1,3 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -9,9 +11,11 @@ import { differenceInDays } from 'date-fns';
@Injectable()
export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {}
public async get(): Promise<AdminData> {
@ -107,7 +111,8 @@ export class AdminService {
}
},
createdAt: true,
id: true
id: true,
Subscription: true
},
take: 30,
where: {
@ -118,16 +123,23 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id }) => {
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription(Subscription)
: undefined;
return {
alias,
createdAt,
engagement,
id,
subscription,
accountCount: _count.Account || 0,
lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0

View File

@ -4,7 +4,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,

View File

@ -1,5 +1,6 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
}),
SubscriptionModule
],
providers: [
AuthDeviceService,

View File

@ -2,7 +2,7 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,

View File

@ -1,6 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -1,7 +1,7 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -1,5 +1,5 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -90,6 +90,27 @@ export class InfoService {
});
}
private async countGitHubContributors(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const contributors = await get();
return contributors?.length;
} catch (error) {
console.error(error);
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> {
try {
const get = bent(
@ -131,11 +152,13 @@ export class InfoService {
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
return {
activeUsers1d,
activeUsers30d,
gitHubContributors,
gitHubStargazers
};
}

View File

@ -6,7 +6,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -6,6 +6,8 @@ export interface CurrentPositions {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big;
totalInvestment: Big;
}

View File

@ -5,6 +5,7 @@ import Big from 'big.js';
export interface PortfolioOrder {
currency: Currency;
date: string;
fee: Big;
name: string;
quantity: Big;
symbol: string;

View File

@ -11,6 +11,9 @@ export interface PortfolioPositionDetail {
marketPrice: number;
maxPrice: number;
minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number;
symbol: string;
transactionCount: number;

View File

@ -4,5 +4,6 @@ export interface TimelinePeriod {
date: string;
grossPerformance: Big;
investment: Big;
netPerformance: Big;
value: Big;
}

View File

@ -3,6 +3,7 @@ import Big from 'big.js';
export interface TransactionPointSymbol {
currency: Currency;
fee: Big;
firstBuyDate: string;
investment: Big;
quantity: Big;

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,7 @@ export class PortfolioCalculator {
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
@ -72,6 +73,7 @@ export class PortfolioCalculator {
} else {
currentTransactionPointItem = {
currency: order.currency,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
@ -112,11 +114,13 @@ export class PortfolioCalculator {
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
currentValue: new Big(0),
hasErrors: false,
positions: [],
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
currentValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
};
}
@ -181,7 +185,9 @@ export class PortfolioCalculator {
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
@ -190,6 +196,7 @@ export class PortfolioCalculator {
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
@ -202,10 +209,6 @@ export class PortfolioCalculator {
const items = this.transactionPoints[i].items;
for (const item of items) {
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
if (!oldHoldingPeriodReturn) {
oldHoldingPeriodReturn = new Big(1);
}
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
@ -224,6 +227,13 @@ export class PortfolioCalculator {
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment;
investedValue = item.investment;
@ -247,18 +257,26 @@ export class PortfolioCalculator {
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
holdingPeriodReturns[item.symbol] = (
holdingPeriodReturns[item.symbol] ?? new Big(1)
).mul(holdingPeriodReturn);
grossPerformance[item.symbol] = (
grossPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue));
const netHoldingPeriodReturn = endValue.div(
initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
}
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
}
}
@ -282,15 +300,17 @@ export class PortfolioCalculator {
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
netPerformancePercentage:
isValid && netHoldingPeriodReturns[item.symbol]
? netHoldingPeriodReturns[item.symbol].minus(1)
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallGrossPerformance(
positions,
initialValues
);
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
@ -378,7 +398,7 @@ export class PortfolioCalculator {
return flatten(timelinePeriods);
}
private calculateOverallGrossPerformance(
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
) {
@ -387,6 +407,8 @@ export class PortfolioCalculator {
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
@ -401,6 +423,7 @@ export class PortfolioCalculator {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
@ -414,6 +437,9 @@ export class PortfolioCalculator {
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
console.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
@ -425,6 +451,8 @@ export class PortfolioCalculator {
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
}
return {
@ -432,6 +460,8 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
@ -442,6 +472,7 @@ export class PortfolioCalculator {
endDate: Date
): Promise<TimelinePeriod[]> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
@ -454,6 +485,7 @@ export class PortfolioCalculator {
currencies[item.symbol] = item.currency;
symbols.push(item.symbol);
investment = investment.add(item.investment);
fees = fees.add(item.fee);
}
let marketSymbols: GetValueObject[] = [];
@ -490,7 +522,7 @@ export class PortfolioCalculator {
}
}
const results = [];
const results: TimelinePeriod[] = [];
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
@ -513,11 +545,13 @@ export class PortfolioCalculator {
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const result = {
date: currentDateAsString,
grossPerformance: value.minus(investment),
grossPerformance,
investment,
value
value,
date: currentDateAsString,
netPerformance: grossPerformance.minus(fees)
};
results.push(result);
}

View File

@ -5,13 +5,13 @@ import {
} from '@ghostfolio/api/helper/object.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -124,18 +124,11 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
): Promise<PortfolioDetails> {
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(impersonationId, range);
try {
details = await this.portfolioService.getDetails(impersonationId, range);
} catch (error) {
console.error(error);
res.status(StatusCodes.ACCEPTED);
}
if (hasNotDefinedValuesInObject(details)) {
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED);
}
@ -143,13 +136,13 @@ export class PortfolioController {
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(details)
const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => {
return portfolioPosition.investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(details)
const totalValue = Object.values(holdings)
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
@ -159,24 +152,21 @@ export class PortfolioController {
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(details)) {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null;
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
for (const [account, { current, original }] of Object.entries(
portfolioPosition.accounts
)) {
portfolioPosition.accounts[account].current = current / totalValue;
portfolioPosition.accounts[account].original =
original / totalInvestment;
}
portfolioPosition.quantity = null;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment;
}
}
return <any>res.json(details);
return <any>res.json({ accounts, holdings });
}
@Get('performance')
@ -286,6 +276,7 @@ export class PortfolioController {
position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
}

View File

@ -24,15 +24,15 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
PortfolioDetails,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
PortfolioSummary,
Position,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
import type {
DateRange,
OrderWithAccount,
RequestWithUser
@ -147,14 +147,14 @@ export class PortfolioService {
.map((timelineItem) => ({
date: timelineItem.date,
marketPrice: timelineItem.value,
value: timelineItem.grossPerformance.toNumber()
value: timelineItem.netPerformance.toNumber()
}));
}
public async getDetails(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ [symbol: string]: PortfolioPosition }> {
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId);
const userCurrency = this.request.user.Settings.currency;
@ -168,7 +168,7 @@ export class PortfolioService {
});
if (transactionPoints?.length <= 0) {
return {};
return { accounts: {}, holdings: {}, hasErrors: false };
}
portfolioCalculator.setTransactionPoints(transactionPoints);
@ -179,16 +179,12 @@ export class PortfolioService {
startDate
);
if (currentPositions.hasErrors) {
throw new Error('Missing information');
}
const cashDetails = await this.accountService.getCashDetails(
userId,
userCurrency
);
const result: { [symbol: string]: PortfolioPosition } = {};
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
);
@ -212,26 +208,33 @@ export class PortfolioService {
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
for (const item of currentPositions.positions) {
if (item.quantity.lte(0)) {
// Ignore positions without any quantity
continue;
}
const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
result[item.symbol] = {
accounts,
holdings[item.symbol] = {
allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
currency: item.currency,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance.toNumber(),
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0,
investment: item.investment.toNumber(),
marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState,
name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors,
symbol: item.symbol,
@ -241,13 +244,20 @@ export class PortfolioService {
}
// TODO: Add a cash position for each currency
result[ghostfolioCashSymbol] = await this.getCashPosition({
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment: totalInvestment,
value: totalValue
});
return result;
const accounts = await this.getAccounts(
orders,
portfolioItemsNow,
userCurrency,
userId
);
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
}
public async getPosition(
@ -272,6 +282,9 @@ export class PortfolioService {
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
@ -279,10 +292,12 @@ export class PortfolioService {
}
const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
@ -316,7 +331,7 @@ export class PortfolioService {
transactionCount
} = position;
// Convert investment and gross performance to currency of user
// Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(),
@ -328,6 +343,11 @@ export class PortfolioService {
currency,
userCurrency
);
const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
@ -337,10 +357,10 @@ export class PortfolioService {
);
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = orders[0].unitPrice;
let minPrice = orders[0].unitPrice;
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
if (!historicalData[aSymbol][firstBuyDate]) {
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
@ -389,10 +409,13 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
name,
netPerformance,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(),
symbol: aSymbol
};
@ -435,6 +458,7 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
name,
averagePrice: 0,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
@ -442,6 +466,8 @@ export class PortfolioService {
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: 0,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: 0,
symbol: aSymbol,
transactionCount: undefined
@ -505,6 +531,9 @@ export class PortfolioService {
investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState,
name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
position.netPerformancePercentage?.toNumber() ?? null,
quantity: new Big(position.quantity).toNumber()
};
})
@ -530,6 +559,8 @@ export class PortfolioService {
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
}
};
@ -549,11 +580,17 @@ export class PortfolioService {
currentPositions.grossPerformance.toNumber();
const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage.toNumber();
const currentNetPerformance = currentPositions.netPerformance.toNumber();
const currentNetPerformancePercent =
currentPositions.netPerformancePercentage.toNumber();
return {
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentGrossPerformance,
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue: currentValue
}
};
@ -604,7 +641,12 @@ export class PortfolioService {
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
const accounts = await this.getAccounts(
orders,
portfolioItemsNow,
baseCurrency,
userId
);
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
@ -704,21 +746,13 @@ export class PortfolioService {
investment: Big;
value: Big;
}) {
const accounts = {};
const cashValue = new Big(cashDetails.balance);
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue.div(value).toNumber(),
allocationInvestment: cashValue.div(investment).toNumber(),
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
@ -727,6 +761,8 @@ export class PortfolioService {
marketPrice: 0,
marketState: MarketState.open,
name: 'Cash',
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
@ -773,6 +809,13 @@ export class PortfolioService {
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
)
),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
@ -797,41 +840,67 @@ export class PortfolioService {
};
}
private getAccounts(
private async getAccounts(
orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency
userCurrency: Currency,
userId: string
) {
const accounts: PortfolioPosition['accounts'] = {};
for (const order of orders) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
order.currency,
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
const accounts: PortfolioDetails['accounts'] = {};
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
const currentAccounts = await this.accountService.getAccounts(userId);
for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => {
return accountId === account.id;
});
if (ordersByAccount.length <= 0) {
// Add account without orders
const balance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.name] = {
current: balance,
original: balance
};
continue;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
for (const order of ordersByAccount) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
order.currency,
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
}
}
return accounts;
}

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService]
providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -1,7 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns';
import { Subscription } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe';
@Injectable()
@ -86,4 +88,23 @@ export class SubscriptionService {
console.error(error);
}
}
public getSubscription(aSubscriptions: Subscription[]) {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
return {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
return {
type: SubscriptionType.Basic
};
}
}
}

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -11,6 +11,7 @@ import {
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@ -48,6 +49,15 @@ export class SymbolController {
@Get(':symbol')
@UseGuards(AuthGuard('jwt'))
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
return this.symbolService.get(symbol);
const result = await this.symbolService.get(symbol);
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
}
}

View File

@ -15,13 +15,17 @@ export class SymbolService {
public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]);
const { currency, dataSource, marketPrice } = response[aSymbol];
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
return {
dataSource,
marketPrice,
currency: <Currency>(<unknown>currency)
};
if (dataSource && marketPrice) {
return {
dataSource,
marketPrice,
currency: <Currency>(<unknown>currency)
};
}
return undefined;
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {

View File

@ -4,7 +4,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -11,7 +12,8 @@ import { UserService } from './user.service';
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
})
}),
SubscriptionModule
],
controllers: [UserController],
providers: [ConfigurationService, PrismaService, UserService],

View File

@ -1,3 +1,4 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config';
@ -6,7 +7,6 @@ import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
@ -19,7 +19,8 @@ export class UserService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {}
public async getUser({
@ -98,24 +99,9 @@ export class UserService {
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) {
const latestSubscription = userFromDatabase.Subscription.reduce(
(a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription
);
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {

View File

@ -1,16 +1,17 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
PortfolioDetails,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
private accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Current Investment'

View File

@ -1,6 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
PortfolioDetails,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
@ -8,9 +11,7 @@ import { Rule } from '../../rule';
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
private accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Initial Investment'

View File

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
@ -7,9 +8,7 @@ import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
private accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Single Account'

View File

@ -1,4 +1,8 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import {
benchmarks,
currencyPairs,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getUtc,
@ -38,7 +42,7 @@ export class DataGatheringService {
if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.');
console.time('7d-data-gathering');
console.time('data-gathering-7d');
await this.prismaService.property.create({
data: {
@ -71,7 +75,7 @@ export class DataGatheringService {
});
console.log('7d data gathering has been completed.');
console.timeEnd('7d-data-gathering');
console.timeEnd('data-gathering-7d');
}
}
@ -82,7 +86,7 @@ export class DataGatheringService {
if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.');
console.time('max-data-gathering');
console.time('data-gathering-max');
await this.prismaService.property.create({
data: {
@ -115,13 +119,13 @@ export class DataGatheringService {
});
console.log('Max data gathering has been completed.');
console.timeEnd('max-data-gathering');
console.timeEnd('data-gathering-max');
}
}
public async gatherProfileData(aSymbols?: string[]) {
console.log('Profile data gathering has been started.');
console.time('profile-data-gathering');
console.time('data-gathering-profile');
let symbols = aSymbols;
@ -136,12 +140,14 @@ export class DataGatheringService {
for (const [
symbol,
{ assetClass, currency, dataSource, name }
{ assetClass, assetSubClass, countries, currency, dataSource, name }
] of Object.entries(currentData)) {
try {
await this.prismaService.symbolProfile.upsert({
create: {
assetClass,
assetSubClass,
countries,
currency,
dataSource,
name,
@ -149,6 +155,8 @@ export class DataGatheringService {
},
update: {
assetClass,
assetSubClass,
countries,
currency,
name
},
@ -165,7 +173,7 @@ export class DataGatheringService {
}
console.log('Profile data gathering has been completed.');
console.timeEnd('profile-data-gathering');
console.timeEnd('data-gathering-profile');
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -291,7 +299,7 @@ export class DataGatheringService {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX'
symbol: ghostfolioFearAndGreedIndexSymbol
});
}

View File

@ -19,7 +19,10 @@ import { format } from 'date-fns';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
import {
convertToYahooFinanceSymbol,
YahooFinanceService
} from './yahoo-finance/yahoo-finance.service';
@Injectable()
export class DataProviderService {
@ -47,12 +50,16 @@ export class DataProviderService {
}
}
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
return (
!isGhostfolioScraperApiSymbol(symbol) &&
!isRakutenRapidApiSymbol(symbol)
);
});
const yahooFinanceSymbols = aSymbols
.filter((symbol) => {
return (
!isGhostfolioScraperApiSymbol(symbol) &&
!isRakutenRapidApiSymbol(symbol)
);
})
.map((symbol) => {
return convertToYahooFinanceSymbol(symbol);
});
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);

View File

@ -1,6 +1,7 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getToday,
@ -47,11 +48,11 @@ export class RakutenRapidApiService implements DataProviderInterface {
try {
const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') {
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
'GF.FEAR_AND_GREED_INDEX': {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value,
@ -82,7 +83,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
try {
const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') {
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
try {
@ -118,7 +119,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} catch {}
return {
'GF.FEAR_AND_GREED_INDEX': {
[ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
}

View File

@ -25,6 +25,7 @@ export interface IYahooFinancePrice {
}
export interface IYahooFinanceSummaryProfile {
country?: string;
industry?: string;
sector?: string;
website?: string;

View File

@ -8,8 +8,15 @@ import {
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { AssetClass, Currency, DataSource } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
Currency,
DataSource
} from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js';
import { countries } from 'countries-list';
import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
@ -21,6 +28,7 @@ import {
} from '../../interfaces/interfaces';
import {
IYahooFinanceHistoricalResponse,
IYahooFinancePrice,
IYahooFinanceQuoteResponse
} from './interfaces/interfaces';
@ -35,16 +43,12 @@ export class YahooFinanceService implements DataProviderInterface {
}
public async get(
aSymbols: string[]
aYahooFinanceSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
if (aYahooFinanceSymbols.length <= 0) {
return {};
}
const yahooSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol);
});
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
@ -52,15 +56,18 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({
modules: ['price', 'summaryProfile'],
symbols: yahooSymbols
symbols: aYahooFinanceSymbols
});
for (const [yahooSymbol, value] of Object.entries(data)) {
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol);
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
response[symbol] = {
assetClass: this.parseAssetClass(value.price?.quoteType),
assetClass,
assetSubClass,
currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName),
@ -72,6 +79,33 @@ export class YahooFinanceService implements DataProviderInterface {
name: value.price?.longName || value.price?.shortName || symbol
};
if (value.price?.currency === 'GBp') {
// Convert GBp (pence) to GBP
response[symbol].currency = Currency.GBP;
response[symbol].marketPrice = new Big(
value.price?.regularMarketPrice ?? 0
)
.div(100)
.toNumber();
}
// Add country if stock and available
if (
assetSubClass === AssetSubClass.STOCK &&
value.summaryProfile?.country
) {
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === value.summaryProfile?.country;
});
if (code) {
response[symbol].countries = [{ code, weight: 1 }];
}
} catch {}
}
// Add url if available
const url = value.summaryProfile?.website;
if (url) {
response[symbol].url = url;
@ -98,15 +132,15 @@ export class YahooFinanceService implements DataProviderInterface {
return {};
}
const yahooSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol);
const yahooFinanceSymbols = aSymbols.map((symbol) => {
return convertToYahooFinanceSymbol(symbol);
});
try {
const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({
symbols: yahooSymbols,
symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT)
});
@ -115,9 +149,11 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
for (const [yahooSymbol, timeSeries] of Object.entries(historicalData)) {
for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol);
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
timeSeries.forEach((timeSerie) => {
@ -137,7 +173,7 @@ export class YahooFinanceService implements DataProviderInterface {
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
const items: LookupItem[] = [];
try {
const get = bent(
@ -154,19 +190,6 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
@ -182,56 +205,48 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
currency: marketData[symbol]?.currency,
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
for (const [symbol, value] of Object.entries(marketData)) {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
name: value.name
});
}
} catch {}
return { items };
}
/**
* Converts a symbol to a Yahoo symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
private convertToYahooSymbol(aSymbol: string) {
if (isCurrency(aSymbol)) {
if (isCrypto(aSymbol)) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring(
aSymbol.length - 3
)}`;
}
return `${aSymbol}=X`;
}
return aSymbol;
}
private parseAssetClass(aString: string): AssetClass {
private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (aString?.toLowerCase()) {
switch (aPrice?.quoteType?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
}
return assetClass;
return { assetClass, assetSubClass };
}
private parseExchange(aString: string): string {
@ -243,7 +258,30 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
export const convertFromYahooSymbol = (aSymbol: string) => {
const symbol = aSymbol.replace('-', '');
export const convertFromYahooFinanceSymbol = (aYahooFinanceSymbol: string) => {
const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', '');
};
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
export const convertToYahooFinanceSymbol = (aSymbol: string) => {
if (isCurrency(aSymbol)) {
if (isCrypto(aSymbol)) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring(
aSymbol.length - 3
)}`;
}
return `${aSymbol}=X`;
}
return aSymbol;
};

View File

@ -3,7 +3,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { format } from 'date-fns';
import { isNumber } from 'lodash';
import { isEmpty, isNumber } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service';
@ -35,6 +35,24 @@ export class ExchangeRateDataService {
getYesterday()
);
if (isEmpty(result)) {
// Load currencies directly from data provider as a fallback
// if historical data is not yet available
const historicalData = await this.dataProviderService.get(
this.currencyPairs.map((currencyPair) => {
return currencyPair;
})
);
Object.keys(historicalData).forEach((key) => {
result[key] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: historicalData[key].marketPrice
}
};
});
}
const resultExtended = result;
Object.keys(result).forEach((pair) => {

View File

@ -1,6 +1,7 @@
import {
Account,
AssetClass,
AssetSubClass,
Currency,
DataSource,
SymbolProfile
@ -35,6 +36,8 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: Currency;
dataSource: DataSource;
exchange?: string;

View File

@ -1,9 +1,15 @@
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { AssetClass, Currency, DataSource } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
Currency,
DataSource
} from '@prisma/client';
export interface EnhancedSymbolProfile {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
createdAt: Date;
currency: Currency | null;
dataSource: DataSource;

View File

@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { AccountsTableComponent } from './accounts-table.component';
@NgModule({

View File

@ -5,7 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { HeaderComponent } from './header.component';

View File

@ -3,12 +3,12 @@ import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { GfFearAndGreedIndexModule } from '../fear-and-greed-index/fear-and-greed-index.module';
import { GfValueModule } from '../value/value.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({

View File

@ -37,7 +37,7 @@
[colorizeSign]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentGrossPerformance"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
</div>
<div class="col">
@ -46,7 +46,7 @@
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentGrossPerformancePercent
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>

View File

@ -52,7 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
new CountUp(
'value',
this.performance?.currentGrossPerformancePercent * 100,
this.performance?.currentNetPerformancePercent * 100,
{
decimalPlaces: 2,
duration: 0.75,

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceComponent } from './portfolio-performance.component';
@NgModule({

View File

@ -16,6 +16,8 @@ import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js';
import { Chart } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import * as Color from 'color';
@Component({
selector: 'gf-portfolio-proportion-chart',
@ -28,9 +30,10 @@ export class PortfolioProportionChartComponent
{
@Input() baseCurrency: Currency;
@Input() isInPercent: boolean;
@Input() key: string;
@Input() keys: string[];
@Input() locale: string;
@Input() maxItems?: number;
@Input() showLabels = false;
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
};
@ -47,7 +50,13 @@ export class PortfolioProportionChartComponent
};
public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
Chart.register(
ArcElement,
ChartDataLabels,
DoughnutController,
LinearScale,
Tooltip
);
}
public ngOnInit() {}
@ -65,24 +74,54 @@ export class PortfolioProportionChartComponent
private initialize() {
this.isLoading = true;
const chartData: {
[symbol: string]: { color?: string; value: number };
[symbol: string]: {
color?: string;
subCategory: { [symbol: string]: { value: number } };
value: number;
};
} = {};
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]].value +=
if (this.positions[symbol][this.keys[0]]) {
if (chartData[this.positions[symbol][this.keys[0]]]) {
chartData[this.positions[symbol][this.keys[0]]].value +=
this.positions[symbol].value;
if (
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]]
]
) {
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]]
].value += this.positions[symbol].value;
} else {
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY
] = { value: this.positions[symbol].value };
}
} else {
chartData[this.positions[symbol][this.key]] = {
chartData[this.positions[symbol][this.keys[0]]] = {
subCategory: {},
value: this.positions[symbol].value
};
if (this.positions[symbol][this.keys[1]]) {
chartData[this.positions[symbol][this.keys[0]]].subCategory = {
[this.positions[symbol][this.keys[1]]]: {
value: this.positions[symbol].value
}
};
}
}
} else {
if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
} else {
chartData[UNKNOWN_KEY] = {
subCategory: this.keys[1]
? { [this.keys[1]]: { value: 0 } }
: undefined,
value: this.positions[symbol].value
};
}
@ -107,13 +146,17 @@ export class PortfolioProportionChartComponent
});
if (!unknownItem) {
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]);
const index = chartDataSorted.push([
UNKNOWN_KEY,
{ subCategory: {}, value: 0 }
]);
unknownItem = chartDataSorted[index];
}
rest.forEach((restItem) => {
if (unknownItem?.[1]) {
unknownItem[1] = {
subCategory: {},
value: unknownItem[1].value + restItem[1].value
};
}
@ -132,7 +175,8 @@ export class PortfolioProportionChartComponent
// Reuse color
item.color = this.colorMap[symbol];
} else {
const color = this.getColorPalette()[index];
const color =
this.getColorPalette()[index % this.getColorPalette().length];
// Store color for reuse
this.colorMap[symbol] = color;
@ -141,21 +185,53 @@ export class PortfolioProportionChartComponent
}
});
const backgroundColorSubCategory: string[] = [];
const dataSubCategory: number[] = [];
const labelSubCategory: string[] = [];
chartDataSorted.forEach(([, item]) => {
let lightnessRatio = 0.2;
Object.keys(item.subCategory).forEach((subCategory) => {
backgroundColorSubCategory.push(
Color(item.color).lighten(lightnessRatio).hex()
);
dataSubCategory.push(item.subCategory[subCategory].value);
labelSubCategory.push(subCategory);
lightnessRatio += 0.1;
});
});
const datasets = [
{
backgroundColor: chartDataSorted.map(([, item]) => {
return item.color;
}),
borderWidth: 0,
data: chartDataSorted.map(([, item]) => {
return item.value;
})
}
];
let labels = chartDataSorted.map(([label]) => {
return label;
});
if (this.keys[1]) {
datasets.unshift({
backgroundColor: backgroundColorSubCategory,
borderWidth: 0,
data: dataSubCategory
});
labels = labelSubCategory.concat(labels);
}
const data = {
datasets: [
{
backgroundColor: chartDataSorted.map(([, item]) => {
return item.color;
}),
borderWidth: 0,
data: chartDataSorted.map(([, item]) => {
return item.value;
})
}
],
labels: chartDataSorted.map(([label]) => {
return label;
})
datasets,
labels
};
if (this.chartCanvas) {
@ -166,13 +242,39 @@ export class PortfolioProportionChartComponent
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
cutout: '70%',
layout: {
padding: this.showLabels === true ? 100 : 0
},
plugins: {
datalabels: {
color: (context) => {
return this.getColorPalette()[
context.dataIndex % this.getColorPalette().length
];
},
display: this.showLabels === true ? 'auto' : false,
labels: {
index: {
align: 'end',
anchor: 'end',
formatter: (value, context) => {
return value > 0
? context.chart.data.labels[context.dataIndex]
: '';
},
offset: 8
}
}
},
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => {
const label =
context.label === UNKNOWN_KEY ? 'Other' : context.label;
const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ??
0) + context.dataIndex;
const label = context.chart.data.labels[labelIndex];
if (this.isInPercent) {
const value = 100 * <number>context.raw;

View File

@ -9,23 +9,6 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.fees"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">
@ -66,7 +49,7 @@
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Performance</div>
<div class="d-flex flex-grow-1" i18n>Absolute Gross Performance</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
@ -77,7 +60,7 @@
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Performance (TWR)</div>
<div class="d-flex flex-grow-1 ml-3" i18n>Gross Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
@ -91,6 +74,48 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.fees"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Net Performance</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Net Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfValueModule } from '../value/value.module';
import { PortfolioSummaryComponent } from './portfolio-summary.component';
@NgModule({

View File

@ -3,5 +3,4 @@ export interface PositionDetailDialogParams {
deviceType: string;
locale: string;
symbol: string;
title: string;
}

View File

@ -34,7 +34,11 @@ export class PositionDetailDialog implements OnDestroy {
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public name: string;
public netPerformance: number;
public netPerformancePercent: number;
public quantity: number;
public symbol: string;
public transactionCount: number;
private unsubscribeSubject = new Subject<void>();
@ -60,7 +64,11 @@ export class PositionDetailDialog implements OnDestroy {
marketPrice,
maxPrice,
minPrice,
name,
netPerformance,
netPerformancePercent,
quantity,
symbol,
transactionCount
}) => {
this.averagePrice = averagePrice;
@ -86,7 +94,11 @@ export class PositionDetailDialog implements OnDestroy {
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.name = name;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.quantity = quantity;
this.symbol = symbol;
this.transactionCount = transactionCount;
if (isToday(parseISO(this.firstBuyDate))) {

View File

@ -1,7 +1,7 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="data.title ?? data.symbol"
[title]="name ?? symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
@ -25,7 +25,7 @@
[colorizeSign]="true"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="grossPerformance"
[value]="netPerformance"
></gf-value>
</div>
<div class="col-6 mb-3">
@ -35,7 +35,7 @@
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="grossPerformancePercent"
[value]="netPerformancePercent"
></gf-value>
</div>
<div class="col-6 mb-3">
@ -80,7 +80,8 @@
<gf-value
label="Quantity"
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[precision]="2"
[value]="quantity"
></gf-value>
</div>
@ -102,9 +103,8 @@
</div>
<div class="col-6 mb-3">
<gf-value
label="Transactions"
size="medium"
[isCurrency]="true"
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
[locale]="data.locale"
[value]="transactionCount"
></gf-value>

View File

@ -3,11 +3,11 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { GfValueModule } from '../../value/value.module';
import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({

View File

@ -11,7 +11,7 @@
[isLoading]="isLoading"
[marketState]="position?.marketState"
[range]="range"
[value]="position?.grossPerformancePercentage"
[value]="position?.netPerformancePercentage"
></gf-trend-indicator>
</div>
<div *ngIf="isLoading" class="flex-grow-1">
@ -47,13 +47,13 @@
[colorizeSign]="true"
[currency]="baseCurrency"
[locale]="locale"
[value]="position?.grossPerformance"
[value]="position?.netPerformance"
></gf-value>
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="position?.grossPerformancePercentage"
[value]="position?.netPerformancePercentage"
></gf-value>
</div>
</div>

View File

@ -64,8 +64,7 @@ export class PositionComponent implements OnDestroy, OnInit {
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale,
symbol: this.position?.symbol,
title: this.position?.name
symbol: this.position?.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -3,10 +3,10 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfTrendIndicatorModule } from '@ghostfolio/ui/trend-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfTrendIndicatorModule } from '../trend-indicator/trend-indicator.module';
import { GfValueModule } from '../value/value.module';
import { GfPositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module';
import { PositionComponent } from './position.component';

View File

@ -30,7 +30,7 @@
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.grossPerformancePercent"
[value]="isLoading ? undefined : element.netPerformancePercent"
></gf-value>
</div>
</td>
@ -87,7 +87,7 @@
}"
(click)="
!this.ignoreAssetClasses.includes(row.assetClass) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
onOpenPositionDialog({ symbol: row.symbol })
"
></tr>
</table>

View File

@ -57,14 +57,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['title']
) {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol'],
title: params['title']
symbol: params['symbol']
});
}
});
@ -96,15 +91,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title }
queryParams: { positionDetailDialog: true, symbol }
});
}
@ -116,18 +105,11 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
});
}
public openPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
title,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale

View File

@ -8,12 +8,12 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { PositionsTableComponent } from './positions-table.component';
@NgModule({

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionModule } from '../position/position.module';
import { PositionsComponent } from './positions.component';

View File

@ -3,8 +3,8 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfRuleModule } from '@ghostfolio/client/components/rule/rule.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionModule } from '../position/position.module';
import { RulesComponent } from './rules.component';

View File

@ -1,4 +1,8 @@
<mat-radio-group [formControl]="option" (change)="onValueChange()">
<mat-radio-group
class="text-nowrap"
[formControl]="option"
(change)="onValueChange()"
>
<mat-radio-button
*ngFor="let option of options"
[disabled]="isLoading"

View File

@ -255,8 +255,7 @@
mat-row
(click)="
onOpenPositionDialog({
symbol: row.symbol,
title: row.SymbolProfile?.name
symbol: row.symbol
})
"
></tr>

View File

@ -86,8 +86,7 @@ export class TransactionsTableComponent
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol'],
title: params['title']
symbol: params['symbol']
});
}
});
@ -196,15 +195,9 @@ export class TransactionsTableComponent
this.import.emit();
}
public onOpenPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title }
queryParams: { positionDetailDialog: true, symbol }
});
}
@ -216,18 +209,11 @@ export class TransactionsTableComponent
this.transactionToClone.emit(aTransaction);
}
public openPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
title,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale

View File

@ -10,11 +10,11 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { TransactionsTableComponent } from './transactions-table.component';
@NgModule({

View File

@ -35,12 +35,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
) {
const { globalPermissions, statistics } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
@ -59,7 +54,12 @@ export class AboutPageComponent implements OnDestroy, OnInit {
);
this.statistics = statistics;
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {

View File

@ -10,8 +10,10 @@
stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions. The source code is fully available as open
source software (OSS). The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a> and is driven by the
efforts of its
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
@ -19,16 +21,32 @@
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.</ng-container
last published on {{ lastPublish }}.
</ng-container>
<ng-container *ngIf="hasPermissionForSubscription" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new feature, please tweet to
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
an issue at
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
@ -76,27 +94,37 @@
<mat-card>
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
{{ statistics?.activeUsers1d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 24 hours)</small>
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 24 hours)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 30 days)</small>
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.gitHubContributors">
{{ statistics?.gitHubContributors ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
</div>
<div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
{{ statistics?.gitHubStargazers ?? '-' }}
</h3>
<div class="h6 mb-0">Stars on GitHub</div>
<div class="h6 mb-0" i18n>Stars on GitHub</div>
</div>
</div>
</mat-card-content>

View File

@ -9,12 +9,12 @@
<mat-card class="mb-3">
<mat-card-content>
<div *ngIf="user.alias" class="d-flex py-1">
<div class="w-50" i18n>Alias</div>
<div class="w-50">{{ user.alias }}</div>
<div class="pr-1 w-50" i18n>Alias</div>
<div class="pl-1 w-50">{{ user.alias }}</div>
</div>
<div *ngIf="user?.subscription" class="d-flex py-1">
<div class="w-50" i18n>Membership</div>
<div class="w-50">
<div class="pr-1 w-50" i18n>Membership</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex mb-1">
{{ user?.subscription?.type }}
<ion-icon
@ -51,13 +51,14 @@
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50">
<div i18n>Restricted View</div>
<div class="pr-1 w-50">
<div i18n>Presenter View</div>
<div class="hint-text text-muted" i18n>
Hides absolute values like performances and quantities.
Hides sensitive values such as absolute performances and
quantities.
</div>
</div>
<div class="w-50">
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
[checked]="user.settings.isRestrictedView"
@ -69,10 +70,10 @@
<div class="d-flex mt-4 py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 w-50" i18n>
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n>
Base Currency
</div>
<div class="w-50">
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="baseCurrency"
@ -90,7 +91,7 @@
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pt-1 w-50" i18n>
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
View Mode
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
@ -98,7 +99,7 @@
name="diamond-outline"
></ion-icon>
</div>
<div class="w-50">
<div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden">
<mat-form-field appearance="outline" class="w-100">
<mat-select
@ -117,8 +118,8 @@
</form>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50" i18n>Sign in with fingerprint</div>
<div class="w-50">
<div class="pr-1 w-50" i18n>Sign in with fingerprint</div>
<div class="pl-1 w-50">
<mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary"

View File

@ -116,7 +116,20 @@
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }}
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>
<ion-icon
*ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }}

View File

@ -20,6 +20,7 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import {
PortfolioPerformance,
PortfolioSummary,
@ -58,6 +59,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number;
public hasImpersonationId: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true;
@ -110,7 +112,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem('GF.FEAR_AND_GREED_INDEX')
.fetchSymbolItem(ghostfolioFearAndGreedIndexSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
@ -119,6 +121,11 @@ export class HomePageComponent implements OnDestroy, OnInit {
});
}
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
@ -135,6 +142,8 @@ export class HomePageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
this.changeDetectorRef.markForCheck();
});
this.dateRange =

View File

@ -91,18 +91,28 @@
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<mat-card *ngIf="hasPositions === true" class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<ng-container *ngIf="hasPositions === true">
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</ng-container>
<div
*ngIf="hasPositions === false"
class="d-flex justify-content-center"

View File

@ -5,12 +5,12 @@ import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/client/components/no-transactions-info/no-transactions-info.module';
import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { HomePageRoutingModule } from './home-page-routing.module';
import { HomePageComponent } from './home-page.component';

View File

@ -16,6 +16,9 @@
right: 0;
top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-tab-body-wrapper {
height: 100%;

View File

@ -3,7 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { LandingPageRoutingModule } from './landing-page-routing.module';
import { LandingPageComponent } from './landing-page.component';

View File

@ -4,7 +4,12 @@ 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 {
PortfolioDetails,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { AssetClass } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -31,12 +36,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public portfolioDetails: PortfolioDetails;
public positions: { [symbol: string]: any };
public positionsArray: PortfolioPosition[];
public sectors: {
[name: string]: { name: string; value: number };
};
public symbols: {
[name: string]: { name: string; value: number };
};
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -66,11 +75,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
});
this.dataService
.fetchPortfolioPositions({})
.fetchPortfolioDetails({})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response = {}) => {
this.portfolioPositions = response;
this.initializeAnalysisData(this.portfolioPositions, this.period);
.subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData(this.period);
this.changeDetectorRef.markForCheck();
});
@ -86,12 +96,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
});
}
public initializeAnalysisData(
aPortfolioPositions: {
[symbol: string]: PortfolioPosition;
},
aPeriod: string
) {
public initializeAnalysisData(aPeriod: string) {
this.accounts = {};
this.continents = {
[UNKNOWN_KEY]: {
@ -113,10 +118,28 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0
}
};
this.symbols = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
for (const [name, { current, original }] of Object.entries(
this.portfolioDetails.accounts
)) {
this.accounts[name] = {
name,
value: aPeriod === 'original' ? original : current
};
}
for (const [symbol, position] of Object.entries(
this.portfolioDetails.holdings
)) {
this.positions[symbol] = {
assetClass: position.assetClass,
assetSubClass: position.assetSubClass,
currency: position.currency,
exchange: position.exchange,
value:
@ -126,84 +149,81 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
};
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;
if (position.assetClass !== AssetClass.CASH) {
// Prepare analysis data by continents, countries and sectors except for cash
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.portfolioDetails.holdings[symbol].investment
: this.portfolioDetails.holdings[symbol].value)
};
}
if (this.countries[code]?.value) {
this.countries[code].value += weight * position.value;
} else {
this.countries[code] = {
name,
value:
weight *
(aPeriod === 'original'
? this.portfolioDetails.holdings[symbol].investment
: this.portfolioDetails.holdings[symbol].value)
};
}
}
} else {
this.accounts[account] = {
name: account,
value: aPeriod === 'original' ? original : current
};
this.continents[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioDetails.holdings[symbol].investment
: this.portfolioDetails.holdings[symbol].value;
this.countries[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioDetails.holdings[symbol].investment
: this.portfolioDetails.holdings[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.portfolioDetails.holdings[symbol].investment
: this.portfolioDetails.holdings[symbol].value)
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioDetails.holdings[symbol].investment
: this.portfolioDetails.holdings[symbol].value;
}
}
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;
if (position.assetClass === AssetClass.EQUITY) {
this.symbols[symbol] = {
name: symbol,
value: aPeriod === 'original' ? position.investment : position.value
};
}
}
}
@ -211,7 +231,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public onChangePeriod(aValue: string) {
this.period = aValue;
this.initializeAnalysisData(this.portfolioPositions, this.period);
this.initializeAnalysisData(this.period);
}
public ngOnDestroy() {

View File

@ -5,10 +5,10 @@
</div>
</div>
<div class="proportion-charts row">
<div class="col-md-6">
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Account</mat-card-title>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Account</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -18,19 +18,21 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId"
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="accounts"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Asset Class</mat-card-title>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Asset Class</mat-card-title
>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -40,19 +42,21 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="assetClass"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[keys]="['assetClass', 'assetSubClass']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Currency</mat-card-title>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Currency</mat-card-title
>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -62,19 +66,19 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="currency"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[keys]="['currency']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<div class="col-md-12 allocations-by-symbol">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Sector</mat-card-title>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Symbol</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -84,9 +88,33 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
class="mx-auto"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" 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
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="sectors"
@ -94,10 +122,12 @@
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Continent</mat-card-title>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Continent</mat-card-title
>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -107,19 +137,19 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="continents"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Country</mat-card-title>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Country</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -129,7 +159,7 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[keys]="['name']"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
@ -143,8 +173,8 @@
<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>
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Regions</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"

View File

@ -1,9 +1,7 @@
:host {
.proportion-charts {
.mat-card {
.mat-card-content {
padding: 1rem 2rem;
}
.allocations-by-symbol {
gf-portfolio-proportion-chart {
max-width: 80vh;
}
}

View File

@ -11,8 +11,9 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Currency } from '@prisma/client';
import { Observable, Subject } from 'rxjs';
import { EMPTY, Observable, Subject } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
startWith,
@ -49,7 +50,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
) {}
ngOnInit() {
public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
@ -84,17 +85,45 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.data.transaction.unitPrice = this.currentMarketPrice;
}
public onBlurSymbol() {
const symbol = this.searchSymbolCtrl.value;
this.updateSymbol(symbol);
}
public onCancel(): void {
this.dialogRef.close();
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
this.updateSymbol(event.option.value);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private updateSymbol(symbol: string) {
this.isLoading = true;
this.data.transaction.symbol = event.option.value;
this.data.transaction.symbol = symbol;
this.dataService
.fetchSymbolItem(this.data.transaction.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(
catchError(() => {
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.unitPrice = null;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ currency, dataSource, marketPrice }) => {
this.data.transaction.currency = currency;
this.data.transaction.dataSource = dataSource;
@ -105,17 +134,4 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.changeDetectorRef.markForCheck();
});
}
public onUpdateSymbolByTyping(value: string) {
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.unitPrice = null;
this.data.transaction.symbol = value;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -29,7 +29,7 @@
required
[formControl]="searchSymbolCtrl"
[matAutocomplete]="auto"
(change)="onUpdateSymbolByTyping($event.target.value)"
(blur)="onBlurSymbol()"
/>
<mat-autocomplete
#auto="matAutocomplete"

View File

@ -9,8 +9,8 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfValueModule } from '@ghostfolio/client/components/value/value.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';

View File

@ -3,7 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { RegisterPageRoutingModule } from './register-page-routing.module';
import { RegisterPageComponent } from './register-page.component';

View File

@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
import { WebauthnPageRoutingModule } from './webauthn-page-routing.module';

View File

@ -19,6 +19,7 @@ import {
Position,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@ -36,6 +37,7 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
public dateRange: DateRange = 'max';
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true;
@ -63,6 +65,11 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
@ -76,6 +83,8 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
this.changeDetectorRef.markForCheck();
});
this.update();

View File

@ -64,17 +64,28 @@
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
<div class="row">
<div class="align-items-center col">
<mat-card *ngIf="hasPositions === true" class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<ng-container *ngIf="hasPositions === true">
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</ng-container>
<div
*ngIf="hasPositions === false"
class="d-flex justify-content-center"

View File

@ -5,9 +5,9 @@ import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/client/components/no-transactions-info/no-transactions-info.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { ZenPageRoutingModule } from './zen-page-routing.module';
import { ZenPageComponent } from './zen-page.component';

View File

@ -12,6 +12,9 @@
right: 0;
top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-tab-body-wrapper {
height: 100%;

View File

@ -20,8 +20,8 @@ import {
AdminData,
Export,
InfoItem,
PortfolioDetails,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
PortfolioSummary,
User
@ -148,17 +148,16 @@ export class DataService {
return this.http.get<InvestmentItem[]>('/api/portfolio/investments');
}
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
return this.http.get<PortfolioPerformance>('/api/portfolio/performance', {
public fetchPortfolioDetails(aParams: { [param: string]: any }) {
return this.http.get<PortfolioDetails>('/api/portfolio/details', {
params: aParams
});
}
public fetchPortfolioPositions(aParams: { [param: string]: any }) {
return this.http.get<{ [symbol: string]: PortfolioPosition }>(
'/api/portfolio/details',
{ params: aParams }
);
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
return this.http.get<PortfolioPerformance>('/api/portfolio/performance', {
params: aParams
});
}
public fetchPortfolioReport() {

View File

@ -27,7 +27,10 @@
name="twitter:title"
content="Ghostfolio Open Source Wealth Management Software"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
/>
<meta property="og:description" content="" />
<meta
property="og:title"

View File

@ -1,6 +1,7 @@
/* You can add global styles to this file, and also import other style files */
@import './styles/bootstrap';
@import './styles/table';
@import '~angular-material-css-vars/main';
@ -68,6 +69,10 @@ body {
}
}
.gf-table {
@include gf-table(true);
}
.mat-card {
background: var(--dark-background);
@ -129,6 +134,10 @@ ngx-skeleton-loader {
}
}
.gf-table {
@include gf-table;
}
.mat-fab,
.mat-flat-button {
&.mat-primary {
@ -147,18 +156,6 @@ ngx-skeleton-loader {
margin: 0 !important;
}
.mat-row {
&:last-child {
td.mat-cell {
border-bottom-width: 0;
}
}
}
.mat-table {
background: transparent !important;
}
.no-min-width {
min-width: unset !important;
}

View File

@ -4,31 +4,3 @@ $mat-css-dark-theme-selector: '.is-dark-theme';
$alpha-disabled-text: 0.38;
$alpha-hover: 0.04;
.gf-table {
td {
border: 0;
}
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
.is-dark-theme {
.gf-table {
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
}

View File

@ -0,0 +1,27 @@
@mixin gf-table($darkTheme: false) {
background: transparent !important;
td {
border: 0 !important;
}
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--palette-foreground-base),
var(--palette-background-hover-alpha)
);
}
}
@if $darkTheme {
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--palette-foreground-base-dark),
var(--palette-background-hover-alpha)
);
}
}
}
}

View File

@ -0,0 +1,17 @@
{
"extends": ["plugin:cypress/recommended", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["src/plugins/index.js"],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"no-undef": "off"
}
}
]
}

13
apps/ui-e2e/cypress.json Normal file
View File

@ -0,0 +1,13 @@
{
"fileServerFolder": ".",
"fixturesFolder": "./src/fixtures",
"integrationFolder": "./src/integration",
"modifyObstructiveCode": false,
"supportFile": "./src/support/index.ts",
"pluginsFile": "./src/plugins/index",
"video": true,
"videosFolder": "../../dist/cypress/apps/ui-e2e/videos",
"screenshotsFolder": "../../dist/cypress/apps/ui-e2e/screenshots",
"chromeWebSecurity": false,
"baseUrl": "http://localhost:4400"
}

Some files were not shown because too many files have changed in this diff Show More