Compare commits

..

81 Commits

Author SHA1 Message Date
d147a66dcd Release 1.210.0 (#1434) 2022-11-08 17:35:43 +01:00
33fd1282e5 Bugfix/fix cash position in user currency (#1433)
* Initialize cash positions with user currency

* Update changelog
2022-11-08 17:32:50 +01:00
693ff9d3ea Setup pt (#1432) 2022-11-07 17:17:07 +01:00
21e87a0055 Clean up (#1431) 2022-11-07 17:03:34 +01:00
43426c9b01 Feature/restructure portfolio page (#1429)
* Restructure portfolio page

* Update changelog
2022-11-07 16:57:26 +01:00
3b4da72ea3 Feature/tighten validation rule of base currency (#1428)
* Tighten validation rule of BASE_CURRENCY

* Update changelog
2022-11-06 17:51:31 +01:00
8d8e55fd0b Release 1.209.0 (#1427) 2022-11-05 13:39:26 +01:00
ca18621ce8 Improve locales (#1426) 2022-11-05 12:49:17 +01:00
b8574d24b2 Feature/improve usability of import (#1425)
* Improve usability by adding expected file format

* Update changelog
2022-11-05 12:22:24 +01:00
6d12c27f9c Feature/rename transactions to activities page (#1424)
* Rename transactions to activities

* Update changelog
2022-11-05 10:42:40 +01:00
c2c5326049 Feature/add buy me a coffee badge to about page (#1422)
* Add button: Buy me a coffee

* Update changelog
2022-11-05 10:22:18 +01:00
2a1339b61e Feature/improve usage of premium indicator component (#1421)
* Improve usage of premium indicator component

* Update changelog
2022-11-05 09:12:41 +01:00
c8a2579624 Feature/remove intro image in dark mode (#1420)
* Remove intro image in dark mode

* Update changelog
2022-11-05 09:06:40 +01:00
832ae063df Release 1.208.0 (#1415) 2022-11-03 20:21:12 +01:00
b5e026934f Harmonize extension (before and after) (#1414) 2022-11-03 20:18:40 +01:00
901c997908 Add pagination to activities table (#1404)
* Add pagination to activities table
2022-11-03 20:16:30 +01:00
3b6e0b20e2 Add test case (#1399)
* Add test case

* Fix calculation for portfolio evolution chart

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-11-03 20:06:16 +01:00
e449d51c3c Feature/restructure actions in admin control panel (#1410)
* Restructure actions

* Update changelog
2022-11-02 17:47:03 +01:00
f72d31bab3 Release 1.207.0 (#1409) 2022-10-31 20:27:52 +01:00
4c893c4dcc Bugfix/fix public page (#1408)
* Fix public page

* Update changelog
2022-10-31 20:25:42 +01:00
ffb11cd10e Add instructions for API Authorization (#1406)
* Add instructions for API Authorization
2022-10-31 20:12:42 +01:00
d424b7731e Feature/change background color of dark mode (#1396)
* Darken background color

* Update changelog
2022-10-30 18:48:28 +01:00
6043c87481 Simplify lead (#1401) 2022-10-30 17:02:01 +01:00
fca0a688b6 parse csv date in ISO format (#1303)
* Handle various date formats

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-10-28 11:26:19 +02:00
5c6cc4fed5 Handle division by zero (#1398) 2022-10-26 21:28:26 +02:00
64a7d38ff9 Add more translations (#1394) 2022-10-23 10:28:08 +02:00
68d0d39161 Feature/translate asset and asset sub classes (#1393)
* Translate asset and asset sub classes

* Update changelog
2022-10-22 07:47:05 +02:00
233a8a8a18 Bugfix/improve loading indicator of investment chart (#1392)
* Improve loading indicator

* Update changelog
2022-10-21 20:01:32 +02:00
190779ee35 Release 1.206.2 (#1391) 2022-10-20 23:40:42 +02:00
6ef8121561 Release 1.206.1 (#1390) 2022-10-20 21:49:12 +02:00
58bf57d1e6 Release 1.206.0 (#1389) 2022-10-20 20:58:33 +02:00
71c5412dd5 Add test case: sell partially with huge gain (#1380)
* Add test case: sell partially with huge gain
2022-10-20 20:56:58 +02:00
ae85398c3d Fix test description (#1379) 2022-10-20 20:48:40 +02:00
048900d01b Bugfix/improve performance calculation for sell activitities (#1388)
* Improve performance calculation for SELL activities

* Update changelog

Co-authored-by: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-10-20 20:47:57 +02:00
074b09b543 Add space (#1381) 2022-10-19 07:53:58 +02:00
f9e04022f4 Remove TWR calculation (#1377) 2022-10-18 21:06:28 +02:00
8fd1fbd44a Feature/upgrade nx to version 15 (#1375)
* Upgrade to Nx 15

* Update changelog
2022-10-18 20:05:58 +02:00
0fb33ae71c Feature/migrate angular.json to project.json (#1374)
* Migrate angular.json to project.json

* Update changelog
2022-10-17 20:41:13 +02:00
3a35d72ec2 Fix disabled placeholder color (#1365) 2022-10-17 17:25:50 +02:00
32fe3e195f Release 1.205.2 (#1369) 2022-10-16 20:49:28 +02:00
805f4b05be Release 1.205.1 (#1368) 2022-10-16 20:19:27 +02:00
5b51a6840a Release 1.205.0 (#1367) 2022-10-16 19:36:51 +02:00
36bd6164e6 Feature/improve wording on landing page (#1366)
* Improve wording

* Update changelog
2022-10-16 19:35:09 +02:00
eac52a215b Feature/refactor appearance to color scheme (#1364)
* Refactor appearance to colorScheme

* Update changelog
2022-10-16 14:54:26 +02:00
9ff8cd5471 Feature/improve portfolio evolution chart (#1362)
* Switch inputs

* Update changelog
2022-10-16 10:01:31 +02:00
33cc7e4e7e Feature/remove rakuten from data source (#1361)
* Remove Rakuten

* Update changelog
2022-10-16 09:42:18 +02:00
47f84dab06 Remove postfix (#1360) 2022-10-16 09:41:41 +02:00
384d18b2a6 Feature/persist user language on url change (#1359)
* Persist user language

* Update changelog
2022-10-16 08:45:52 +02:00
2363983bdc Release 1.204.1 (#1357) 2022-10-15 18:07:18 +02:00
4af76764be Release 1.204.0 (#1356) 2022-10-15 17:47:35 +02:00
a65424aafa Feature/add total amount chart to investment timeline (#1344)
* Add total amount chart

* Update changelog
2022-10-15 17:45:34 +02:00
f9cd629470 Update messages.es.xlf (#1355) 2022-10-15 17:44:20 +02:00
ccb8c86596 Feature/minor improvements in chart components (#1353)
* Move y axis to the right

* Update changelog
2022-10-15 11:27:55 +02:00
246de7aa86 Consider current month in FIRE calculation (#1349) 2022-10-15 10:45:11 +02:00
a323313c71 Bugfix/fix alignment of value component on allocation page (#1351)
* Fix alignment

* Update changelog
2022-10-15 10:32:08 +02:00
538c8947cd Feature/rename data source from rakuten to rapid api (#1350)
* Rename Rakuten to Rapid API

* Update changelog
2022-10-15 10:31:46 +02:00
1ec5fd12fe Feature/setup prettier plugin organize attributes (#1346)
* Setup prettier plugin: prettier-plugin-organize-attributes

* Update changelog
2022-10-15 10:30:12 +02:00
4376b8903e Bugifx/fix url in blog posts (#1347)
* Fix url

* Update changelog
2022-10-14 19:52:00 +02:00
a8e096f9ac Feature/minor improvements for appearance selector (#1345)
* Improve appearance selector

* Update changelog
2022-10-12 13:38:58 +02:00
8e577592f6 Fix issue with $localize in storybook (#1343) 2022-10-12 09:48:33 +02:00
c896bf9199 Add appearance option in settings (#1342)
* Add appearance option in settings
2022-10-11 21:34:52 +02:00
16145f18d9 Improve template (#1324)
* Improve template
2022-10-09 10:42:02 +02:00
5398da0dc8 Feature/simplify admin settings management (#1340)
* Simplify settings management

* Update changelog
2022-10-08 17:35:18 +02:00
2466f4ff5d Release 1.203.0 (#1338) 2022-10-08 14:22:43 +02:00
8f3a9bdfbb Feature/refactor animation configuration (#1337)
* Refactor animation configuration

* Update changelog
2022-10-08 14:21:17 +02:00
44dfd2bd48 Add animation to line chart (#1328) 2022-10-08 13:47:48 +02:00
3fc2228f1d Feature/switch to new performance calculation (#1336)
* Switch to new performance calculation

* Update changelog
2022-10-08 13:20:52 +02:00
b018819a1f Bugfix/fix todays performance and chart calculation (#1333)
* Fix today's performance and chart calculation

* Update changelog
2022-10-08 13:20:25 +02:00
ac9311d783 Bugfix/fix alignment in users table (#1335)
* Fix alignment

* Update changelog
2022-10-08 11:38:58 +02:00
e23ce0f35d Feature/improve gui of benchmark comparator (#1334)
* Improve GUI

* Update changelog
2022-10-08 11:07:42 +02:00
f4b52aa41c Add Italian localization for the 4% rule (#1329)
* Update messages.it.xlf
2022-10-08 11:05:53 +02:00
655b040d4d Add missing title (#1332) 2022-10-07 20:54:05 +02:00
0f637a5d0f Release 1.202.0 (#1331) 2022-10-07 20:49:57 +02:00
3f85c327f5 Bugfix/fix text truncation in value component (#1330)
* Fix text truncation

* Update changelog
2022-10-07 20:48:39 +02:00
c2df99072d Feature/refactor filters (#1299)
* Refactor filters

Co-Authored-By: Zakaria YAHI <9142557+ZakYahi@users.noreply.github.com>
2022-10-07 20:39:29 +02:00
e8afbcad9c Feature/localize 4 percentage rule (#1327)
* Setup translation for 4% rule

* Update changelog
2022-10-07 20:21:52 +02:00
e6d8de781b Feature/improve wording in twitter bot service (#1326)
* Improve wording

* Update changelog
2022-10-06 20:52:34 +02:00
6e1935899f Bugfix/fix cryptocurrency symbols with less than 3 characters (#1325)
* Fix cryptocurrency symbols with less than 3 characters

* Update changelog
2022-10-06 15:15:36 +02:00
169cb85b66 Improve Italian translation (#1318)
* Update messages.it.xlf
2022-10-05 07:53:03 +02:00
fe6658d0ac Update messages.es.xlf (#1319) 2022-10-04 17:43:25 +02:00
1f0381228e Feature/improve caching of benchmarks (#1320)
* Improve caching

* Update changelog
2022-10-04 17:39:51 +02:00
155 changed files with 8635 additions and 4778 deletions

View File

@ -1,37 +1,45 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
title: '[BUG]'
labels: ''
assignees: ''
---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Describe the bug**
**Bug Description**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
Steps to reproduce the behavior:
<!-- Steps to reproduce the behavior -->
1.
2.
3.
4.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Logs**
<!-- If applicable, add logs to help explain your problem. -->
**Environment (please complete the following information):**
- Ghostfolio Version [e.g. 1.194.0]
- Browser [e.g. chrome]
- OS
**Environment**
<!-- Please complete the following information -->
- Ghostfolio Version X.Y.Z
- Browser
- OS
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@ -1,4 +1,13 @@
{
"attributeGroups": [
"$ANGULAR_ELEMENT_REF",
"$ANGULAR_STRUCTURAL_DIRECTIVE",
"$DEFAULT",
"$ANGULAR_INPUT",
"$ANGULAR_TWO_WAY_BINDING",
"$ANGULAR_OUTPUT"
],
"attributeSort": "ASC",
"endOfLine": "auto",
"printWidth": 80,
"singleQuote": true,

4
.vscode/launch.json vendored
View File

@ -5,11 +5,11 @@
"name": "Debug Jest File",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"args": [
"test",
"--codeCoverage=false",
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
],
"cwd": "${workspaceFolder}",
"console": "internalConsole"

View File

@ -5,6 +5,141 @@ 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.210.0 - 08.11.2022
### Added
- Added tabs to the portfolio page
### Changed
- Merged the _FIRE_ calculator and the _X-ray_ section to a single page
- Tightened the validation rule of the base currency environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue in the cash positions calculation
## 1.209.0 - 05.11.2022
### Added
- Added the _Buy me a coffee_ button to the about page
### Changed
- Improved the usability of the activities import
- Improved the usage of the premium indicator component
- Removed the intro image in dark mode
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
## 1.208.0 - 03.11.2022
### Added
- Added pagination to the activities table
### Changed
- Restructured the actions in the admin control panel
### Fixed
- Fixed the calculation in the portfolio evolution chart
## 1.207.0 - 31.10.2022
### Added
- Added support for translated labels of asset and asset sub class
- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import
### Changed
- Darkened the background color of the dark mode
### Fixed
- Fixed the public page
- Improved the loading indicator of the portfolio evolution chart
## 1.206.2 - 20.10.2022
### Changed
- Fixed the `rxjs` version to `7.5.6` (resolutions)
- Migrated the `angular.json` to `project.json` files in the `Nx` workspace
- Upgraded `nestjs` from version `9.0.7` to `9.1.4`
- Upgraded `Nx` from version `14.6.4` to `15.0.0`
### Fixed
- Fixed the performance calculation including `SELL` activities with a significant performance gain
## 1.205.2 - 16.10.2022
### Changed
- Persisted the language on url change
- Improved the portfolio evolution chart
- Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`)
- Improved the wording on the landing page
## 1.204.1 - 15.10.2022
### Added
- Added support to change the appearance (dark mode) in user settings
- Added the total amount chart to the investment timeline
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
### Changed
- Respected the current date in the _FIRE_ calculator
- Simplified the settings management in the admin control panel
- Renamed the data source type `RAKUTEN` to `RAPID_API`
### Fixed
- Fixed some links in the blog posts
- Fixed the alignment of the value component on the allocations page
### Todo
- Rename the environment variable from `RAKUTEN_RAPID_API_KEY` to `RAPID_API_API_KEY`
## 1.203.0 - 08.10.2022
### Added
- Supported a progressive line animation in the line chart component
### Changed
- Moved the benchmark comparator from experimental to general availability
- Improved the user interface of the benchmark comparator
### Fixed
- Fixed an issue in the performance and chart calculation of today
- Fixed the alignment of the value component in the admin control panel
## 1.202.0 - 07.10.2022
### Added
- Added support for a translated 4% rule in the _FIRE_ section
### Changed
- Improved the caching of the benchmarks in the markets overview (only cache if fetching was successful)
- Improved the wording in the twitter bot service
### Fixed
- Fixed the support for cryptocurrencies having a symbol with less than 3 characters (e.g. `SC-USD`)
- Fixed the text truncation in the value component
## 1.201.0 - 01.10.2022
### Added
@ -917,8 +1052,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Display the value in base currency in the accounts table on mobile
- Display the value in base currency in the activities table on mobile
- Displayed the value in base currency in the accounts table on mobile
- Displayed the value in base currency in the activities table on mobile
- Renamed `orders` to `activities` in import and export functionality
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
- Improved the pricing page

View File

@ -25,7 +25,6 @@ RUN yarn install
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js

View File

@ -83,20 +83,20 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| Name | Default Value | Description |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
### Run with Docker Compose
@ -128,7 +128,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
Open http://localhost:3333 in your browser and accomplish these steps:
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
#### Upgrade Version
@ -158,7 +158,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Start Server
@ -190,20 +190,22 @@ Run `yarn test`
## Public API
### Authorization: Bearer Token
Set the header for each request as follows:
```
"Authorization": "Bearer eyJh..."
```
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Import Activities
#### Request
`POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body
```

View File

@ -1,395 +0,0 @@
{
"version": 1,
"projects": {
"api": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"]
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
}
},
"tags": []
},
"client": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/client",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"development-es": {
"baseHref": "/es/",
"localize": ["es"]
},
"development-it": {
"baseHref": "/it/",
"localize": ["it"]
},
"development-nl": {
"baseHref": "/nl/",
"localize": ["nl"]
},
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},
"development-es": {
"browserTarget": "client:build:development-es"
},
"development-it": {
"browserTarget": "client:build:development-it"
},
"development-nl": {
"browserTarget": "client:build:development-nl"
},
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
"browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": [
"messages.de.xlf",
"messages.es.xlf",
"messages.it.xlf",
"messages.nl.xlf"
]
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
}
},
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
},
"client-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
},
"common": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
},
"tags": []
},
"ui": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"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.ts",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"outputs": ["{options.outputPath}"],
"options": {
"outputDir": "dist/storybook/ui",
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
"quiet": true
}
}
}
},
"tags": []
},
"ui-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"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}"]
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}
}
}

56
apps/api/project.json Normal file
View File

@ -0,0 +1,56 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"generators": {},
"targets": {
"build": {
"executor": "@nrwl/webpack:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"target": "node",
"compiler": "tsc"
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}
},
"tags": []
}

View File

@ -95,11 +95,10 @@ export class AccountController {
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id,
undefined,
true
);
await this.portfolioService.getAccountsWithAggregations({
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||
@ -139,11 +138,11 @@ export class AccountController {
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id,
[{ id, type: 'ACCOUNT' }],
true
);
await this.portfolioService.getAccountsWithAggregations({
filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||

View File

@ -181,10 +181,10 @@ export class AdminService {
public async putSetting(key: string, value: string) {
let response: Property;
if (value === '') {
response = await this.propertyService.delete({ key });
} else {
if (value) {
response = await this.propertyService.put({ key, value });
} else {
response = await this.propertyService.delete({ key });
}
if (key === PROPERTY_CURRENCIES) {

View File

@ -73,6 +73,7 @@ export class BenchmarkService {
}
const allTimeHighs = await Promise.all(promises);
let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
@ -85,6 +86,8 @@ export class BenchmarkService {
allTimeHigh,
marketPrice
);
} else {
storeInCache = false;
}
return {
@ -100,11 +103,13 @@ export class BenchmarkService {
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('4 hours') / 1000
);
if (storeInCache) {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('4 hours') / 1000
);
}
return benchmarks;
}

View File

@ -3,8 +3,8 @@ import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -36,6 +36,7 @@ import { UpdateOrderDto } from './update-order.dto';
@Controller('order')
export class OrderController {
public constructor(
private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
@ -73,30 +74,11 @@ export class OrderController {
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Activities> {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(

View File

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.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';
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
controllers: [OrderController],
exports: [OrderService],
imports: [
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,

View File

@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'BTCUSD':
if (isSameDay(parseDate('2015-01-01'), date)) {
return { marketPrice: 314.25 };
} else if (isSameDay(parseDate('2017-12-31'), date)) {
return { marketPrice: 14156.4 };
} else if (isSameDay(parseDate('2018-01-01'), date)) {
return { marketPrice: 13657.2 };
}
return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };

View File

@ -0,0 +1,110 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2015-01-01',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(2),
symbol: 'BTCUSD',
type: 'BUY',
unitPrice: new Big(320.43)
},
{
currency: 'CHF',
date: '2017-12-31',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(1),
symbol: 'BTCUSD',
type: 'SELL',
unitPrice: new Big(14156.4)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2015-01-01')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('13657.2'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
positions: [
{
averagePrice: new Big('320.43'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
investment: new Big('320.43'),
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
marketPrice: 13657.2,
quantity: new Big('1'),
symbol: 'BTCUSD',
transactionCount: 2
}
],
totalInvestment: new Big('320.43')
});
expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-01', investment: new Big('-14156.4') }
]);
});
});
});

View File

@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
it.only('with NOVN.SW buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',

View File

@ -0,0 +1,130 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData(
parseDate('2022-03-07')
);
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(chartData[0]).toEqual({
date: '2022-03-07',
netPerformanceInPercentage: 0,
netPerformance: 0,
totalInvestment: 151.6,
value: 151.6
});
expect(chartData[chartData.length - 1]).toEqual({
date: '2022-04-11',
netPerformanceInPercentage: 13.100263852242744,
netPerformance: 19.86,
totalInvestment: 0,
value: 19.86
});
expect(currentPositions).toEqual({
currentValue: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
hasErrors: false,
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
investment: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
marketPrice: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-171.46') }
]);
});
});
});

View File

@ -14,6 +14,7 @@ import {
format,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isSameYear,
max,
@ -187,7 +188,9 @@ export class PortfolioCalculator {
day = addDays(day, step);
}
dates.push(resetHours(end));
if (!isSameDay(last(dates), end)) {
dates.push(resetHours(end));
}
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
@ -231,21 +234,28 @@ export class PortfolioCalculator {
[symbol: string]: { [date: string]: Big };
} = {};
const maxInvestmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {};
const maxTotalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) {
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
}
for (const currentDate of dates) {
@ -264,19 +274,28 @@ export class PortfolioCalculator {
totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0);
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[
dateString
].add(investmentValuesBySymbol[symbol][dateString]);
}
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
dateString
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
}
}
}
return Object.keys(totalNetPerformanceValues).map((date) => {
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
? 0
: totalNetPerformanceValues[date]
.div(totalInvestmentValues[date])
.div(maxTotalInvestmentValues[date])
.mul(100)
.toNumber();
@ -284,7 +303,10 @@ export class PortfolioCalculator {
date,
netPerformanceInPercentage,
netPerformance: totalNetPerformanceValues[date].toNumber(),
value: netPerformanceInPercentage
totalInvestment: totalInvestmentValues[date].toNumber(),
value: totalInvestmentValues[date]
.plus(totalNetPerformanceValues[date])
.toNumber()
};
});
}
@ -893,13 +915,14 @@ export class PortfolioCalculator {
let initialValue: Big;
let investmentAtStartDate: Big;
const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
// let lastTransactionInvestment = new Big(0);
// let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
// let timeWeightedGrossPerformancePercentage = new Big(1);
// let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
@ -994,6 +1017,12 @@ export class PortfolioCalculator {
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(i + 1, order.type, order.itemType);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
@ -1021,9 +1050,21 @@ export class PortfolioCalculator {
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
const transactionInvestment =
order.type === 'BUY'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(this.getFactor(order.type))
: new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
}
totalInvestment = totalInvestment.plus(transactionInvestment);
@ -1072,58 +1113,69 @@ export class PortfolioCalculator {
? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'totalInvestmentWithGrossPerformanceFromSell',
totalInvestmentWithGrossPerformanceFromSell.toNumber()
);
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell)
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
// if (
// i > indexOfStartOrder &&
// !lastValueOfInvestmentBeforeTransaction
// .plus(lastTransactionInvestment)
// .eq(0)
// ) {
// const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
// .minus(
// lastValueOfInvestmentBeforeTransaction.plus(
// lastTransactionInvestment
// )
// )
// .div(
// lastValueOfInvestmentBeforeTransaction.plus(
// lastTransactionInvestment
// )
// );
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
// timeWeightedGrossPerformancePercentage =
// timeWeightedGrossPerformancePercentage.mul(
// new Big(1).plus(grossHoldingPeriodReturn)
// );
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
// const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
// .minus(fees.minus(feesAtStartDate))
// .minus(
// lastValueOfInvestmentBeforeTransaction.plus(
// lastTransactionInvestment
// )
// )
// .div(
// lastValueOfInvestmentBeforeTransaction.plus(
// lastTransactionInvestment
// )
// );
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
}
// timeWeightedNetPerformancePercentage =
// timeWeightedNetPerformancePercentage.mul(
// new Big(1).plus(netHoldingPeriodReturn)
// );
// }
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
// lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
// lastValueOfInvestmentBeforeTransaction =
// valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
@ -1136,6 +1188,15 @@ export class PortfolioCalculator {
.minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
}
if (i === indexOfEndOrder) {
@ -1143,11 +1204,11 @@ export class PortfolioCalculator {
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
// timeWeightedGrossPerformancePercentage =
// timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
// timeWeightedNetPerformancePercentage =
// timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
@ -1212,6 +1273,7 @@ export class PortfolioCalculator {
Average price: ${averagePriceAtStartDate.toFixed(
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
@ -1227,6 +1289,7 @@ export class PortfolioCalculator {
initialValue,
grossPerformancePercentage,
investmentValues,
maxInvestmentValues,
netPerformancePercentage,
netPerformanceValues,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),

View File

@ -7,18 +7,15 @@ import {
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import {
Filter,
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
PortfolioReport
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
@ -40,6 +37,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import Big from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -52,6 +50,7 @@ export class PortfolioController {
public constructor(
private readonly accessService: AccessService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@ -61,55 +60,6 @@ export class PortfolioController {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
range
);
let chartData = historicalDataContainer.items;
let hasError = false;
chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) {
hasError = true;
}
});
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
let maxValue = 0;
chartData.forEach((portfolioItem) => {
if (portfolioItem.value > maxValue) {
maxValue = portfolioItem.value;
}
});
chartData = chartData.map((historicalDataItem) => {
return {
...historicalDataItem,
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
};
});
}
return {
hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
};
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@ -118,37 +68,21 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasDetails = true;
let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
let portfolioSummary: PortfolioSummary;
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const {
accounts,
@ -158,18 +92,18 @@ export class PortfolioController {
holdings,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails(
} = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
this.request.user.id,
range,
filters
);
userId: this.request.user.id
});
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true;
}
portfolioSummary = summary;
let portfolioSummary = summary;
if (
impersonationId ||
@ -204,7 +138,13 @@ export class PortfolioController {
accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment;
}
}
if (
hasDetails === false ||
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
@ -222,11 +162,6 @@ export class PortfolioController {
]);
}
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
@ -246,7 +181,7 @@ export class PortfolioController {
hasError,
holdings,
totalValueInBaseCurrency,
summary: hasDetails ? portfolioSummary : undefined
summary: portfolioSummary
};
}
@ -254,27 +189,22 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId,
'month'
);
groupBy: 'month'
});
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId
});
}
if (
@ -292,33 +222,16 @@ export class PortfolioController {
}));
}
return { firstOrderDate: parseDate(investments[0]?.date), investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
);
if (
impersonationId ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentGrossPerformance', 'currentValue']
);
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
}
return performanceInformation;
return { investments };
}
@Get('performance')
@ -327,23 +240,53 @@ export class PortfolioController {
@Version('2')
public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string,
@Query('range') dateRange
@Query('range') dateRange: DateRange = 'max'
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformanceV2(
{
dateRange,
impersonationId
}
);
const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
impersonationId,
userId: this.request.user.id
});
if (
impersonationId ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
) {
performanceInformation.chart = performanceInformation.chart.map(
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
return {
date,
netPerformanceInPercentage,
totalInvestment: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
value: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
};
}
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentGrossPerformance', 'currentNetPerformance', 'currentValue']
[
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'totalInvestment'
]
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
}
);
}
@ -355,11 +298,11 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
@Query('range') dateRange: DateRange = 'max'
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
dateRange
);
if (
@ -400,12 +343,12 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId,
'max',
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
);
const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id
});
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
@ -486,16 +429,19 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
for (const rule in report.rules) {
if (report.rules[rule]) {
report.rules[rule] = [];
}
}
}
return await this.portfolioService.getReport(impersonationId);
return report;
}
}

View File

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.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';
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
exports: [PortfolioService],
imports: [
AccessModule,
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

View File

@ -3,7 +3,6 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -32,11 +31,13 @@ import {
HistoricalDataItem,
PortfolioDetails,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReport,
PortfolioSummary,
Position,
TimelinePosition,
UserSettings
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
@ -67,11 +68,9 @@ import {
isAfter,
isBefore,
max,
parse,
parseISO,
set,
setDayOfYear,
startOfDay,
subDays,
subYears
} from 'date-fns';
@ -107,15 +106,19 @@ export class PortfolioService {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(
aUserId: string,
aFilters?: Filter[],
public async getAccounts({
filters,
userId,
withExcludedAccounts = false
): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: aUserId };
}: {
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId };
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
where.id = aFilters[0].id;
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') {
where.id = filters[0].id;
}
const [accounts, details] = await Promise.all([
@ -124,13 +127,12 @@ export class PortfolioService {
include: { Order: true, Platform: true },
orderBy: { name: 'asc' }
}),
this.getDetails(
aUserId,
aUserId,
undefined,
aFilters,
withExcludedAccounts
)
this.getDetails({
filters,
withExcludedAccounts,
impersonationId: userId,
userId: this.request.user.id
})
]);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
@ -168,16 +170,20 @@ export class PortfolioService {
});
}
public async getAccountsWithAggregations(
aUserId: string,
aFilters?: Filter[],
public async getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts = false
): Promise<Accounts> {
const accounts = await this.getAccounts(
aUserId,
aFilters,
}: {
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<Accounts> {
const accounts = await this.getAccounts({
filters,
userId,
withExcludedAccounts
);
});
let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
@ -200,11 +206,16 @@ export class PortfolioService {
};
}
public async getInvestments(
aImpersonationId: string,
groupBy?: GroupBy
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
public async getInvestments({
dateRange,
impersonationId,
groupBy
}: {
dateRange: DateRange;
impersonationId: string;
groupBy?: GroupBy;
}): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -276,108 +287,32 @@ export class PortfolioService {
}
}
return sortBy(investments, (investment) => {
investments = sortBy(investments, (investment) => {
return investment.date;
});
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const startDate = this.getStartDate(
dateRange,
parseDate(investments[0]?.date)
);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
return investments.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
let portfolioStart = parse(
transactionPoints[0].date,
DATE_FORMAT,
new Date()
);
// Get start date for the full portfolio because of because of the
// min and max calculation
portfolioStart = this.getStartDate('max', portfolioStart);
const timelineSpecification: TimelineSpecification[] = [
{
start: format(portfolioStart, DATE_FORMAT),
accuracy: 'day'
}
];
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
format(new Date(), DATE_FORMAT)
);
const timeline = timelineInfo.timelinePeriods;
const items = timeline
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
value: timelineItem.netPerformance.toNumber()
}));
let lastItem = null;
if (timeline.length > 0) {
lastItem = timeline[timeline.length - 1];
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance ?? 0
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance ?? 0
);
if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false;
isAllTimeLow = false;
}
portfolioStart = startOfDay(
this.getStartDate(
aDateRange,
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
)
);
return {
isAllTimeHigh,
isAllTimeLow,
items: items.filter((item) => {
// Filter items of date range
return !isAfter(portfolioStart, parseDate(item.date));
})
};
}
public async getChartV2({
public async getChart({
dateRange = 'max',
impersonationId
impersonationId,
userCurrency,
userId
}: {
dateRange?: DateRange;
impersonationId: string;
userCurrency: string;
userId: string;
}): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -385,7 +320,7 @@ export class PortfolioService {
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
@ -421,29 +356,32 @@ export class PortfolioService {
};
}
public async getDetails(
aImpersonationId: string,
aUserId: string,
aDateRange: DateRange = 'max',
aFilters?: Filter[],
public async getDetails({
impersonationId,
dateRange = 'max',
filters,
userId,
withExcludedAccounts = false
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId);
}: {
impersonationId: string;
dateRange?: DateRange;
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const userCurrency =
user.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId,
withExcludedAccounts,
filters: aFilters
withExcludedAccounts
});
const portfolioCalculator = new PortfolioCalculator({
@ -457,15 +395,15 @@ export class PortfolioService {
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency,
filters: aFilters
currency: userCurrency
});
const holdings: PortfolioDetails['holdings'] = {};
@ -475,10 +413,10 @@ export class PortfolioService {
let filteredValueInBaseCurrency = currentPositions.currentValue;
if (
aFilters?.length === 0 ||
(aFilters?.length === 1 &&
aFilters[0].type === 'ASSET_CLASS' &&
aFilters[0].id === 'CASH')
filters?.length === 0 ||
(filters?.length === 1 &&
filters[0].type === 'ASSET_CLASS' &&
filters[0].id === 'CASH')
) {
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency
@ -574,10 +512,10 @@ export class PortfolioService {
}
if (
aFilters?.length === 0 ||
(aFilters?.length === 1 &&
aFilters[0].type === 'ASSET_CLASS' &&
aFilters[0].id === 'CASH')
filters?.length === 0 ||
(filters?.length === 1 &&
filters[0].type === 'ASSET_CLASS' &&
filters[0].id === 'CASH')
) {
const cashPositions = await this.getCashPositions({
cashDetails,
@ -593,15 +531,19 @@ export class PortfolioService {
}
const accounts = await this.getValueOfAccounts({
filters,
orders,
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts,
filters: aFilters
withExcludedAccounts
});
const summary = await this.getSummary(aImpersonationId);
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId
});
return {
accounts,
@ -621,8 +563,9 @@ export class PortfolioService {
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const orders = (
await this.orderService.getOrders({
@ -942,85 +885,18 @@ export class PortfolioService {
};
}
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
}
};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const hasErrors = currentPositions.hasErrors;
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = currentPositions.grossPerformance;
let currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage;
const currentNetPerformance = currentPositions.netPerformance;
let currentNetPerformancePercent =
currentPositions.netPerformancePercentage;
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
}
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
}
return {
errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentValue,
currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
}
};
}
public async getPerformanceV2({
public async getPerformance({
dateRange = 'max',
impersonationId
impersonationId,
userId
}: {
dateRange?: DateRange;
impersonationId: string;
userId: string;
}): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -1028,7 +904,7 @@ export class PortfolioService {
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
@ -1036,13 +912,15 @@ export class PortfolioService {
if (transactionPoints?.length <= 0) {
return {
chart: [],
firstOrderDate: undefined,
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
currentValue: 0,
totalInvestment: 0
}
};
}
@ -1063,6 +941,7 @@ export class PortfolioService {
let currentNetPerformance = currentPositions.netPerformance;
let currentNetPerformancePercent =
currentPositions.netPerformancePercentage;
const totalInvestment = currentPositions.totalInvestment;
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
@ -1074,9 +953,11 @@ export class PortfolioService {
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
// }
const historicalDataContainer = await this.getChartV2({
const historicalDataContainer = await this.getChart({
dateRange,
impersonationId
impersonationId,
userCurrency,
userId
});
const itemOfToday = historicalDataContainer.items.find((item) => {
@ -1092,14 +973,24 @@ export class PortfolioService {
return {
chart: historicalDataContainer.items.map(
({ date, netPerformanceInPercentage }) => {
({
date,
netPerformance,
netPerformanceInPercentage,
totalInvestment,
value
}) => {
return {
date,
value: netPerformanceInPercentage
netPerformance,
netPerformanceInPercentage,
totalInvestment,
value
};
}
),
errors: currentPositions.errors,
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentValue,
@ -1107,14 +998,16 @@ export class PortfolioService {
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
totalInvestment: totalInvestment.toNumber()
}
};
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -1128,7 +1021,7 @@ export class PortfolioService {
}
const portfolioCalculator = new PortfolioCalculator({
currency,
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
@ -1148,7 +1041,7 @@ export class PortfolioService {
orders,
portfolioItemsNow,
userId,
userCurrency: currency
userCurrency
});
return {
rules: {
@ -1195,7 +1088,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees(orders).toNumber()
this.getFees({ orders, userCurrency }).toNumber()
)
],
<UserSettings>this.request.user.Settings.settings
@ -1217,7 +1110,12 @@ export class PortfolioService {
value: Big;
userCurrency: string;
}) {
const cashPositions: PortfolioDetails['holdings'] = {};
const cashPositions: PortfolioDetails['holdings'] = {
[userCurrency]: this.getInitialCashPosition({
balance: 0,
currency: userCurrency
})
};
for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency(
@ -1234,28 +1132,10 @@ export class PortfolioService {
cashPositions[account.currency].investment += convertedBalance;
cashPositions[account.currency].value += convertedBalance;
} else {
cashPositions[account.currency] = {
allocationCurrent: 0,
allocationInvestment: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
currency: account.currency,
dataSource: undefined,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
marketState: 'open',
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: account.currency,
transactionCount: 0,
value: convertedBalance
};
cashPositions[account.currency] = this.getInitialCashPosition({
balance: convertedBalance,
currency: account.currency
});
}
}
@ -1298,7 +1178,15 @@ export class PortfolioService {
return cashPositions;
}
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
private getDividend({
date = new Date(0),
orders,
userCurrency
}: {
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
@ -1311,7 +1199,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
userCurrency
);
})
.reduce(
@ -1320,7 +1208,15 @@ export class PortfolioService {
);
}
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
private getFees({
date = new Date(0),
orders,
userCurrency
}: {
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders
.filter((order) => {
// Filter out all orders before given date
@ -1330,7 +1226,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
userCurrency
);
})
.reduce(
@ -1339,6 +1235,37 @@ export class PortfolioService {
);
}
private getInitialCashPosition({
balance,
currency
}: {
balance: number;
currency: string;
}): PortfolioPosition {
return {
currency,
allocationCurrent: 0,
allocationInvestment: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
dataSource: undefined,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: balance,
marketPrice: 0,
marketState: 'open',
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: currency,
transactionCount: 0,
value: balance
};
}
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
@ -1379,14 +1306,22 @@ export class PortfolioService {
return portfolioStart;
}
private async getSummary(
aImpersonationId: string
): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
private async getSummary({
impersonationId,
userCurrency,
userId
}: {
impersonationId: string;
userCurrency: string;
userId: string;
}): Promise<PortfolioSummary> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance(aImpersonationId);
const performanceInformation = await this.getPerformance({
impersonationId,
userId
});
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId,
@ -1407,11 +1342,11 @@ export class PortfolioService {
return account?.isExcluded ?? false;
});
const dividend = this.getDividend(orders).toNumber();
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const fees = this.getFees(orders).toNumber();
const fees = this.getFees({ orders, userCurrency }).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
@ -1679,4 +1614,12 @@ export class PortfolioService {
})
.reduce((previous, current) => previous + current, 0);
}
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
);
}
}

View File

@ -1,4 +1,8 @@
import type { DateRange, ViewMode } from '@ghostfolio/common/types';
import type {
ColorScheme,
DateRange,
ViewMode
} from '@ghostfolio/common/types';
import {
IsBoolean,
IsIn,
@ -16,6 +20,10 @@ export class UpdateUserSettingDto {
@IsOptional()
benchmark?: string;
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
@IsOptional()
colorScheme?: ColorScheme;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
@IsOptional()
dateRange?: DateRange;

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ApiService } from './api.service';
@Module({
exports: [ApiService],
providers: [ApiService]
})
export class ApiModule {}

View File

@ -0,0 +1,42 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ApiService {
public constructor() {}
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
return [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
}
}

View File

@ -12,7 +12,10 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: 'USD'
}),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
@ -38,7 +41,7 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),

View File

@ -280,7 +280,7 @@ export class DataGatheringService {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
dataSource !== DataSource.RAPID_API
);
})
.map(({ dataSource, symbol }) => {

View File

@ -5,7 +5,7 @@ import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
@ -27,7 +27,7 @@ import { DataProviderService } from './data-provider.service';
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RakutenRapidApiService,
RapidApiService,
YahooFinanceService,
{
inject: [
@ -36,7 +36,7 @@ import { DataProviderService } from './data-provider.service';
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RakutenRapidApiService,
RapidApiService,
YahooFinanceService
],
provide: 'DataProviderInterfaces',
@ -46,7 +46,7 @@ import { DataProviderService } from './data-provider.service';
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rakutenRapidApiService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
@ -54,7 +54,7 @@ import { DataProviderService } from './data-provider.service';
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rakutenRapidApiService,
rapidApiService,
yahooFinanceService
]
}

View File

@ -1 +0,0 @@
export interface IRakutenRapidApiResponse {}

View File

@ -0,0 +1 @@
export interface IRapidApiResponse {}

View File

@ -15,14 +15,14 @@ import bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable()
export class RakutenRapidApiService implements DataProviderInterface {
export class RapidApiService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {}
public canHandle(symbol: string) {
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
return !!this.configurationService.get('RAPID_API_API_KEY');
}
public async getAssetProfile(
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
}
public getName(): DataSource {
return DataSource.RAKUTEN;
return DataSource.RAPID_API;
}
public async getQuotes(
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
};
}
} catch (error) {
Logger.error(error, 'RakutenRapidApiService');
Logger.error(error, 'RapidApiService');
}
return {};
@ -155,16 +155,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
{
useQueryString: true,
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
'x-rapidapi-key': this.configurationService.get(
'RAKUTEN_RAPID_API_KEY'
)
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
}
);
const { fgi } = await get();
return fgi;
} catch (error) {
Logger.error(error, 'RakutenRapidApiService');
Logger.error(error, 'RapidApiService');
return undefined;
}

View File

@ -58,8 +58,15 @@ export class YahooFinanceService implements DataProviderInterface {
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
if (
aSymbol.includes(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
)
) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(

View File

@ -26,7 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number;
PORT: number;
RAKUTEN_RAPID_API_KEY: string;
RAPID_API_API_KEY: string;
REDIS_HOST: string;
REDIS_PASSWORD: string;
REDIS_PORT: number;

View File

@ -1,6 +1,7 @@
import { IsString } from 'class-validator';
import { IsOptional, IsString } from 'class-validator';
export class PropertyDto {
@IsOptional()
@IsString()
value: string;
}

View File

@ -53,13 +53,15 @@ export class TwitterBotService {
symbolItem.marketPrice
);
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${
symbolItem.marketPrice
}/100)`;
const benchmarkListing = await this.getBenchmarkListing(3);
if (benchmarkListing?.length > 1) {
status += '\n\n';
status += % from ATH\n';
status += '± from ATH in %\n';
status += benchmarkListing;
}

View File

@ -0,0 +1,22 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"targets": {
"e2e": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
}

201
apps/client/project.json Normal file
View File

@ -0,0 +1,201 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"generators": {
"@schematics/angular:component": {
"style": "scss"
}
},
"sourceRoot": "apps/client/src",
"prefix": "gf",
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"development-de": {
"baseHref": "/de/",
"localize": ["de"]
},
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"development-es": {
"baseHref": "/es/",
"localize": ["es"]
},
"development-it": {
"baseHref": "/it/",
"localize": ["it"]
},
"development-nl": {
"baseHref": "/nl/",
"localize": ["nl"]
},
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
},
"development-es": {
"browserTarget": "client:build:development-es"
},
"development-it": {
"browserTarget": "client:build:development-it"
},
"development-nl": {
"browserTarget": "client:build:development-nl"
},
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
"browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": [
"messages.de.xlf",
"messages.es.xlf",
"messages.it.xlf",
"messages.nl.xlf"
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/apps/client"]
}
},
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
}

View File

@ -145,48 +145,6 @@ const routes: Routes = [
(m) => m.PortfolioPageModule
)
},
{
path: 'portfolio/activities',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{
path: 'portfolio/allocations',
loadChildren: () =>
import('./pages/portfolio/allocations/allocations-page.module').then(
(m) => m.AllocationsPageModule
)
},
{
path: 'portfolio/analysis',
loadChildren: () =>
import('./pages/portfolio/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'portfolio/fire',
loadChildren: () =>
import('./pages/portfolio/fire/fire-page.module').then(
(m) => m.FirePageModule
)
},
{
path: 'portfolio/holdings',
loadChildren: () =>
import('./pages/portfolio/holdings/holdings-page.module').then(
(m) => m.HoldingsPageModule
)
},
{
path: 'portfolio/report',
loadChildren: () =>
import('./pages/portfolio/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'pricing',
loadChildren: () =>

View File

@ -13,6 +13,7 @@ import {
} from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@ -77,6 +78,8 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.createUserAccount
);
this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck();
});
}
@ -97,13 +100,17 @@ export class AppComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private initializeTheme() {
this.materialCssVarsService.setDarkTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
);
private initializeTheme(userPreferredColorScheme?: ColorScheme) {
const isDarkTheme = userPreferredColorScheme
? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
this.materialCssVarsService.setDarkTheme(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
this.materialCssVarsService.setDarkTheme(event.matches);
if (!this.user?.settings.colorScheme) {
this.materialCssVarsService.setDarkTheme(event.matches);
}
});
this.materialCssVarsService.setPrimaryColor(primaryColorHex);

View File

@ -4,7 +4,7 @@
<form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field
appearance="outline"
class="compact-with-outline flex-grow-1 mr-2 without-hint"
class="compact-with-outline without-hint w-100"
>
<mat-select formControlName="status">
<mat-option></mat-option>
@ -15,14 +15,6 @@
>
</mat-select>
</mat-form-field>
<button
class="mt-1"
color="warn"
mat-flat-button
(click)="onDeleteJobs()"
>
<span i18n>Delete Jobs</span>
</button>
</form>
<table class="gf-table w-100">
<thead>
@ -35,7 +27,21 @@
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2"></th>
<th class="mat-header-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button>
</mat-menu>
</th>
</tr>
</thead>
<tbody>
@ -102,12 +108,12 @@
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container>
</button>

View File

@ -2,6 +2,7 @@
<gf-line-chart
class="mb-4"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="locale"
[showXAxis]="true"
[showYAxis]="true"

View File

@ -150,6 +150,35 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {});
}
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })

View File

@ -13,10 +13,10 @@
<div class="col">
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="symbol"
matSortDirection="asc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="symbol">
@ -101,17 +101,37 @@
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
[matMenuTriggerFor]="assetProfilesActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onGather7Days()">
<ng-container i18n>Gather Recent Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherMax()">
<ng-container i18n>Gather All Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherProfileData()">
<ng-container i18n>Gather Profile Data</ng-container>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -43,7 +42,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -99,7 +97,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
...this.coupons,
{ code: this.generateCouponCode(16), duration: this.couponDuration }
];
this.putCoupons(coupons);
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
}
public onAddCurrency() {
@ -107,7 +105,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
this.putCurrencies(currencies);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
}
}
@ -124,7 +122,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
const coupons = this.coupons.filter((coupon) => {
return coupon.code !== aCouponCode;
});
this.putCoupons(coupons);
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
}
}
@ -137,12 +135,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency;
});
this.putCurrencies(currencies);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
}
}
public onDeleteSystemMessage() {
this.putSystemMessage('');
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
}
public onFlushCache() {
@ -162,44 +160,21 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
}
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.setReadOnlyMode(aEvent.checked);
this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined
});
}
public onSetSystemMessage() {
const systemMessage = prompt($localize`Please set your system message:`);
if (systemMessage) {
this.putSystemMessage(systemMessage);
this.putAdminSetting({
key: PROPERTY_SYSTEM_MESSAGE,
value: systemMessage
});
}
}
@ -236,49 +211,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
return couponCode;
}
private putCoupons(aCoupons: Coupon[]) {
private putAdminSetting({ key, value }: { key: string; value: any }) {
this.dataService
.putAdminSetting(PROPERTY_COUPONS, {
value: JSON.stringify(aCoupons)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private putCurrencies(aCurrencies: string[]) {
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(aCurrencies)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private putSystemMessage(aSystemMessage: string) {
this.dataService
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
value: aSystemMessage
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private setReadOnlyMode(aValue: boolean) {
this.dataService
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
value: aValue ? 'true' : ''
.putAdminSetting(key, {
value: value ? JSON.stringify(value) : undefined
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {

View File

@ -27,53 +27,6 @@
</div>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Data Management</div>
<div class="w-50">
<div class="overflow-hidden">
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onGather7Days()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Recent Data</span>
</button>
</div>
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onGatherMax()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
<div>
<button
class="mb-2 mr-2"
color="accent"
mat-flat-button
(click)="onGatherProfileData()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Profile Data</span>
</button>
</div>
</div>
</div>
</div>
<div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div>
<div class="w-50">
@ -119,10 +72,20 @@
</div>
</div>
</div>
<div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Benchmarks</div>
<div class="w-50">
<table>
<tr *ngFor="let benchmark of info?.benchmarks">
<td class="pl-1">{{ benchmark.symbol }}</td>
</tr>
</table>
</div>
</div>
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
<div class="w-50" i18n>System Message</div>
<div class="w-50">
<div *ngIf="info.systemMessage">
<div *ngIf="info?.systemMessage">
<span>{{ info.systemMessage }}</span>
<button
class="mini-icon mx-1 no-min-width px-2"
@ -133,7 +96,7 @@
</button>
</div>
<button
*ngIf="!info.systemMessage"
*ngIf="!info?.systemMessage"
color="accent"
mat-flat-button
(click)="onSetSystemMessage()"

View File

@ -43,23 +43,23 @@
<td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }}
</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
<gf-value
class="justify-content-end"
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
></gf-value>
</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
<gf-value
class="justify-content-end"
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
<gf-value
class="justify-content-end"
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userItem.engagement"

View File

@ -1,8 +1,7 @@
<div class="row">
<div class="mb-2 row">
<div class="col-md-6 col-xs-12 d-flex">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
<span i18n>Benchmarks</span>
<sup i18n>Beta</sup>
<span i18n>Performance</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
@ -14,13 +13,16 @@
appearance="outline"
class="w-100 without-hint"
color="accent"
[hidden]="benchmarks?.length === 0"
>
<mat-label i18n>Compare with...</mat-label>
<mat-select
name="benchmark"
[disabled]="user?.subscription?.type === 'Basic'"
[value]="benchmark"
(selectionChange)="onChangeBenchmark($event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let symbolProfile of benchmarks"
[value]="symbolProfile.id"
@ -30,14 +32,6 @@
</mat-form-field>
</div>
</div>
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="my-2 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoading"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="chart-container">
<ngx-skeleton-loader
*ngIf="isLoading"

View File

@ -10,7 +10,6 @@ import {
Output,
ViewChild
} from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
@ -24,7 +23,8 @@ import {
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { ColorScheme } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client';
import {
Chart,
LineController,
@ -35,7 +35,6 @@ import {
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { SymbolProfile } from '@prisma/client';
@Component({
selector: 'gf-benchmark-comparator',
@ -47,6 +46,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmark: string;
@Input() benchmarks: Partial<SymbolProfile>[];
@Input() colorScheme: ColorScheme;
@Input() daysInMarket: number;
@Input() isLoading: boolean;
@Input() locale: string;
@ -54,12 +54,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() user: User;
@Output() benchmarkChanged = new EventEmitter<string>();
@Output() dateRangeChanged = new EventEmitter<DateRange>();
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public constructor() {
Chart.register(
@ -86,10 +84,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
this.benchmarkChanged.next(symbolProfileId);
}
public onChangeDateRange(dateRange: DateRange) {
this.dateRangeChanged.next(dateRange);
}
public ngOnDestroy() {
this.chart?.destroy();
}
@ -135,7 +129,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(),
hoverBackgroundColor: getBackgroundColor(this.colorScheme),
hoverRadius: 2,
radius: 0
}
@ -146,7 +140,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
annotation: {
annotations: {
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
@ -159,7 +153,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
}
},
responsive: true,
@ -167,9 +161,9 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
x: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
borderWidth: 1,
color: `rgba(${getTextColor()}, 0.8)`,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false
},
type: 'time',
@ -181,8 +175,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
y: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false,
drawBorder: false
},
@ -198,7 +192,9 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
plugins: [
getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme)
],
type: 'line'
});
}
@ -208,6 +204,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
locale: this.locale,
unit: '%'
}),

View File

@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
@ -13,7 +13,7 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
imports: [
CommonModule,
FormsModule,
GfToggleModule,
GfPremiumIndicatorModule,
MatSelectModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule

View File

@ -127,6 +127,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(

View File

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

View File

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

View File

@ -107,8 +107,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.dataService
.fetchPortfolioPerformance({
range: this.user?.settings?.dateRange,
version: this.user?.settings?.isExperimentalFeatures ? 2 : 1
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
@ -117,35 +116,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.performance = response.performance;
this.isLoadingPerformance = false;
if (this.user?.settings?.isExperimentalFeatures) {
this.historicalDataItems = response.chart.map(({ date, value }) => {
this.historicalDataItems = response.chart.map(
({ date, netPerformanceInPercentage }) => {
return {
date,
value
value: netPerformanceInPercentage
};
});
} else {
this.dataService
.fetchChart({
range: this.user?.settings?.dateRange,
version: 1
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.chart.map(
({ date, value }) => {
return {
date,
value
};
}
);
this.isAllTimeHigh = chartData.isAllTimeHigh;
this.isAllTimeLow = chartData.isAllTimeLow;
this.changeDetectorRef.markForCheck();
});
}
}
);
this.changeDetectorRef.markForCheck();
});

View File

@ -15,16 +15,17 @@
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.isExperimentalFeatures ? undefined : user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
[unit]="user?.settings?.isExperimentalFeatures ? '%' : undefined"
></gf-line-chart>
</div>
</div>

View File

@ -15,14 +15,16 @@ import {
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate,
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy } from '@ghostfolio/common/types';
import { ColorScheme, DateRange, GroupBy } from '@ghostfolio/common/types';
import {
BarController,
BarElement,
@ -35,7 +37,8 @@ import {
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns';
import { last } from 'lodash';
@Component({
selector: 'gf-investment-chart',
@ -44,20 +47,23 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
styleUrls: ['./investment-chart.component.scss']
})
export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: InvestmentItem[] = [];
@Input() colorScheme: ColorScheme;
@Input() currency: string;
@Input() daysInMarket: number;
@Input() groupBy: GroupBy;
@Input() investments: InvestmentItem[];
@Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false;
@Input() isLoading = false;
@Input() locale: string;
@Input() range: DateRange = 'max';
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart;
public isLoading = true;
private data: InvestmentItem[];
public chart: Chart<any>;
private investments: InvestmentItem[];
private values: LineChartItem[];
public constructor() {
Chart.register(
@ -77,7 +83,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
public ngOnChanges() {
if (this.investments) {
if (this.benchmarkDataItems && this.historicalDataItems) {
this.initialize();
}
}
@ -87,49 +93,89 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
private initialize() {
this.isLoading = true;
// Create a clone
this.data = this.investments.map((a) => Object.assign({}, a));
this.investments = this.benchmarkDataItems.map((item) =>
Object.assign({}, item)
);
this.values = this.historicalDataItems.map((item) =>
Object.assign({}, item)
);
if (!this.groupBy && this.data?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.data[0];
this.data.unshift({
...firstItem,
date: subDays(
parseISO(firstItem.date),
this.daysInMarket * 0.05 || 90
).toISOString(),
investment: 0
});
if (!this.groupBy && this.investments?.length > 0) {
let date: string;
if (this.range === 'max') {
// Extend chart by 5% of days in market (before)
date = format(
subDays(
parseISO(this.investments[0].date),
this.daysInMarket * 0.05 || 90
),
DATE_FORMAT
);
this.investments.unshift({
date,
investment: 0
});
this.values.unshift({
date,
value: 0
});
}
// Extend chart by 5% of days in market (after)
const lastItem = this.data[this.data.length - 1];
this.data.push({
...lastItem,
date: addDays(
parseDate(lastItem.date),
date = format(
addDays(
parseDate(last(this.investments).date),
this.daysInMarket * 0.05 || 90
).toISOString()
),
DATE_FORMAT
);
this.investments.push({
date,
investment: last(this.investments).investment
});
this.values.push({ date, value: last(this.values).value });
}
const data = {
labels: this.data.map((investmentItem) => {
return investmentItem.date;
labels: this.historicalDataItems.map(({ date }) => {
return parseDate(date);
}),
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 2,
data: this.data.map((position) => {
return this.isInPercent
? position.investment * 100
: position.investment;
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 1,
data: this.investments.map(({ date, investment }) => {
return {
x: parseDate(date),
y: this.isInPercent ? investment * 100 : investment
};
}),
label: $localize`Deposit`,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
},
stepped: true
},
{
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.values.map(({ date, value }) => {
return {
x: parseDate(date),
y: this.isInPercent ? value * 100 : value
};
}),
fill: false,
label: $localize`Total Amount`,
pointRadius: 0,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
@ -137,8 +183,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
},
stepped: true
}
}
]
};
@ -160,7 +205,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(),
hoverBackgroundColor: getBackgroundColor(this.colorScheme),
hoverRadius: 2,
radius: 0
}
@ -172,13 +217,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
annotations: {
savingsRate: this.savingsRate
? {
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderColor: `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.75)`,
borderWidth: 1,
label: {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderRadius: 2,
color: 'white',
content: 'Savings Rate',
content: $localize`Savings Rate`,
display: true,
font: { size: '10px', weight: 'normal' },
padding: {
@ -193,7 +238,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
: undefined,
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
@ -206,7 +251,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
}
},
responsive: true,
@ -214,9 +259,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
x: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
borderWidth: this.groupBy ? 0 : 1,
color: `rgba(${getTextColor()}, 0.8)`,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false
},
type: 'time',
@ -228,8 +273,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
y: {
display: !this.isInPercent,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false,
drawBorder: false
},
@ -245,18 +290,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
plugins: [
getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme)
],
type: this.groupBy ? 'bar' : 'line'
});
}
}
this.isLoading = false;
}
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.isInPercent ? undefined : this.currency,
locale: this.isInPercent ? undefined : this.locale,
unit: this.isInPercent ? '%' : undefined

View File

@ -22,9 +22,6 @@
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<span *ngIf="summary?.totalSell || summary?.totalSell === 0" class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"

View File

@ -1,7 +1,9 @@
import { ColorScheme } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface PositionDetailDialogParams {
baseCurrency: string;
colorScheme: ColorScheme;
dataSource: DataSource;
deviceType: string;
hasImpersonationId: boolean;

View File

@ -20,11 +20,13 @@
</div>
<gf-line-chart
class="mb-4"
benchmarkLabel="Average Unit Price"
class="mb-4"
[benchmarkDataItems]="benchmarkDataItems"
[colorScheme]="data.colorScheme"
[currency]="SymbolProfile?.currency"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="data.locale"
[showGradient]="true"
[showXAxis]="true"
@ -187,6 +189,7 @@
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
@ -198,6 +201,7 @@
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"

View File

@ -10,7 +10,7 @@
></gf-no-transactions-info-indicator>
</mat-card>
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>
<gf-rule *ngIf="rules?.length === 0" [isLoading]="true"></gf-rule>
<ng-container *ngIf="rules !== null && rules !== undefined">
<gf-rule *ngFor="let rule of rules" [rule]="rule"></gf-rule>
</ng-container>

View File

@ -9,7 +9,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
})
export class RulesComponent {
@Input() hasPermissionToCreateOrder: boolean;
@Input() rules: PortfolioReportRule;
@Input() rules: PortfolioReportRule[];
public constructor() {}
}

View File

@ -21,4 +21,4 @@ import { RulesComponent } from './rules.component';
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class RulesModule {}
export class GfRulesModule {}

View File

@ -5,6 +5,7 @@ import {
Router,
RouterStateSnapshot
} from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { EMPTY } from 'rxjs';
@ -30,6 +31,7 @@ export class AuthGuard implements CanActivate {
];
constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService
@ -74,8 +76,17 @@ export class AuthGuard implements CanActivate {
const userLanguage = user?.settings?.language;
if (userLanguage && document.documentElement.lang !== userLanguage) {
window.location.href = `../${userLanguage}`;
resolve(false);
this.dataService
.putUserSetting({ language: document.documentElement.lang })
.subscribe(() => {
this.userService.remove();
setTimeout(() => {
window.location.reload();
}, 300);
});
resolve(true);
return;
} else if (
state.url.startsWith('/home') &&

View File

@ -96,6 +96,20 @@
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
<div
*ngIf="!hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<a
href="https://www.buymeacoffee.com/ghostfolio"
target="_blank"
title="Support Ghostfolio"
><img
class="mb-2"
src="../assets/images/button-buy-me-a-coffee.png"
width="180"
/></a>
</div>
</div>
</div>
</div>
@ -177,7 +191,7 @@
<a
class="py-2 w-100"
color="primary"
mat-stroked-button
mat-flat-button
[routerLink]="['/faq']"
>FAQ</a
>
@ -189,7 +203,7 @@
<a
class="py-2 w-100"
color="primary"
mat-stroked-button
mat-flat-button
[routerLink]="['/about', 'changelog']"
>Changelog & License</a
>
@ -198,7 +212,7 @@
<a
class="py-2 w-100"
color="primary"
mat-stroked-button
mat-flat-button
[routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>

View File

@ -42,6 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[];
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public coupon: number;
public couponId: string;

View File

@ -167,7 +167,7 @@
</mat-form-field>
</div>
</div>
<div class="d-flex">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>View Mode</ng-container>
</div>
@ -190,6 +190,30 @@
</div>
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>Appearance</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field
appearance="outline"
class="compact-with-outline w-100 without-hint"
>
<mat-select
class="with-placeholder-as-option"
name="colorScheme"
[disabled]="!hasPermissionToUpdateUserSettings"
[placeholder]="appearancePlaceholder"
[value]="user?.settings?.colorScheme"
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)"
>
<mat-option i18n [value]="null">Auto</mat-option>
<mat-option i18n value="LIGHT">Light</mat-option>
<mat-option i18n value="DARK">Dark</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</form>
</div>
<div class="align-items-center d-flex mt-4 py-1">
@ -243,8 +267,8 @@
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>

View File

@ -119,7 +119,7 @@
Anlagestrategie? Ich freue mich über alle, die Ghostfolio
ausprobieren. Bist du überzeugt vom Potential der Software? Jede
Unterstützung für Ghostfolio ist willkommen. Sei es mit einer
<a href="https://ghostfol.io/pricing">Ghostfolio Premium</a>
<a [routerLink]="['/pricing']">Ghostfolio Premium</a>
Subscription zur Finanzierung des Hostings, einem positiven Rating
im
<a

View File

@ -115,7 +115,7 @@
strategy? I'm happy for everyone who tries Ghostfolio. Are you
convinced of its potential? Any support for Ghostfolio is welcome.
Be it with a
<a href="https://ghostfol.io/pricing">Ghostfolio Premium</a>
<a [routerLink]="['/pricing']">Ghostfolio Premium</a>
Subscription to finance the hosting, a positive rating in the
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"

View File

@ -74,7 +74,7 @@
<a [routerLink]="['/markets']">economic situation</a> at this time,
the goal set at the beginning of the year to build a sustainable
business and reach break-even with the SaaS offering (<a
[routerLink]="['/markets']"
[routerLink]="['/pricing']"
>Ghostfolio Premium</a
>) has been achieved. We will continue to leverage the revenue to
further improve the fully managed cloud offering for our paying

View File

@ -3,6 +3,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { format } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@Component({
@ -14,6 +15,7 @@ import { Subject } from 'rxjs';
export class LandingPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public deviceType: string;
public hasPermissionForStatistics: boolean;
public statistics: Statistics;
public testimonials = [
@ -41,7 +43,10 @@ export class LandingPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(private dataService: DataService) {
public constructor(
private dataService: DataService,
private deviceService: DeviceDetectorService
) {
const { globalPermissions, statistics } = this.dataService.fetchInfo();
this.hasPermissionForStatistics = hasPermission(
@ -52,7 +57,9 @@ export class LandingPageComponent implements OnDestroy, OnInit {
this.statistics = statistics;
}
public ngOnInit() {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

View File

@ -1,10 +1,15 @@
<div class="container">
<div class="row">
<div class="col text-center">
<h1 class="font-weight-bold intro my-5">
<h1 class="font-weight-bold intro mt-5">
Manage your wealth like a boss
</h1>
<div>
<p class="lead mb-4">
Ghostfolio is a privacy-first, open source dashboard for your personal
finances. Break down your asset allocation, know your net worth and make
solid, data-driven investment decisions.
</p>
<div class="mb-4">
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
target="_blank"
@ -23,9 +28,9 @@
</div>
<div class="container">
<div class="button-container row">
<div class="button-container mb-5 row">
<div class="align-items-center col d-flex justify-content-center">
<div class="py-5 text-center">
<div class="text-center">
<a
class="d-inline-block"
color="primary"
@ -43,7 +48,10 @@
</div>
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
<div class="col-md-4 d-flex my-1">
<div
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
<a
class="d-block"
title="Ghostfolio in Numbers: Monthly Active Users (MAU)"
@ -57,7 +65,10 @@
>
</a>
</div>
<div class="col-md-4 d-flex my-1">
<div
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
<a
class="d-block"
title="Ghostfolio in Numbers: Stars on GitHub"
@ -71,7 +82,10 @@
>
</a>
</div>
<div class="col-md-4 d-flex my-1">
<div
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
<a
class="d-block"
title="Ghostfolio in Numbers: Pulls on Docker Hub"
@ -139,15 +153,15 @@
</div>
</div>
<div class="row my-5">
<div class="pt-3 row">
<div class="col text-center">
<h2 class="h4 mb-1 text-center">
Protect your <strong>assets</strong>. Refine your
<strong>personal investment strategy</strong>.
</h2>
<p class="lead">
<p class="lead m-0">
Ghostfolio empowers busy people to keep track of stocks, ETFs or
cryptocurrencies and make solid, data-driven investment decisions.
cryptocurrencies without being tracked.
</p>
</div>
</div>
@ -220,7 +234,7 @@
</li>
</ul>
<div class="mt-4 text-center">
<a [routerLink]="['/about']" mat-stroked-button
<a mat-stroked-button [routerLink]="['/about']"
>Learn more about Ghostfolio</a
>
</div>

View File

@ -114,9 +114,7 @@
}
.outro-inner-container {
div {
background-image: url('/assets/intro-dark.jpg') !important;
}
display: none;
}
.video {

View File

@ -2,12 +2,12 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { TransactionsPageComponent } from './transactions-page.component';
import { ActivitiesPageComponent } from './activities-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: TransactionsPageComponent,
component: ActivitiesPageComponent,
path: '',
title: $localize`Activities`
}
@ -17,4 +17,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class TransactionsPageRoutingModule {}
export class ActivitiesPageRoutingModule {}

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -10,35 +9,34 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { isArray } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { ImportActivitiesDialog } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({
host: { class: 'page' },
selector: 'gf-transactions-page',
styleUrls: ['./transactions-page.scss'],
templateUrl: './transactions-page.html'
selector: 'gf-activities-page',
styleUrls: ['./activities-page.scss'],
templateUrl: './activities-page.html'
})
export class TransactionsPageComponent implements OnDestroy, OnInit {
export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[];
public defaultAccountId: string;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPermissionToDeleteOrder: boolean;
public hasPermissionToImportOrders: boolean;
public hasPermissionToCreateActivity: boolean;
public hasPermissionToDeleteActivity: boolean;
public hasPermissionToImportActivities: boolean;
public routeQueryParams: Subscription;
public user: User;
@ -51,24 +49,22 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute,
private router: Router,
private snackBar: MatSnackBar,
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateTransactionDialog();
this.openCreateActivityDialog();
} else if (params['editDialog']) {
if (this.activities) {
const transaction = this.activities.find(({ id }) => {
return id === params['transactionId'];
const activity = this.activities.find(({ id }) => {
return id === params['activityId'];
});
this.openUpdateTransactionDialog(transaction);
this.openUpdateActivityDialog(activity);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
@ -96,7 +92,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
this.hasPermissionToImportOrders =
this.hasPermissionToImportActivities =
hasPermission(globalPermissions, permissions.enableImport) &&
!this.hasImpersonationId;
});
@ -121,7 +117,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.subscribe(({ activities }) => {
this.activities = activities;
if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) {
if (
this.hasPermissionToCreateActivity &&
this.activities?.length <= 0
) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
@ -129,11 +128,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
});
}
public onCloneTransaction(aActivity: Activity) {
this.openCreateTransactionDialog(aActivity);
public onCloneActivity(aActivity: Activity) {
this.openCreateActivityDialog(aActivity);
}
public onDeleteTransaction(aId: string) {
public onDeleteActivity(aId: string) {
this.dataService
.deleteOrder(aId)
.pipe(takeUntil(this.unsubscribeSubject))
@ -183,98 +182,30 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}
public onImport() {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
const dialogRef = this.dialog.open(ImportActivitiesDialog, {
data: <ImportActivitiesDialogParams>{
deviceType: this.deviceType,
user: this.user
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
input.onchange = (event) => {
this.snackBar.open('⏳' + $localize`Importing data...`);
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
await this.importTransactionsService.importJson({
content: content.activities
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
await this.importTransactionsService.importCsv({
fileContent,
userAccounts: this.user.accounts
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
}
};
};
input.click();
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchActivities();
});
}
public onUpdateTransaction(aTransaction: OrderModel) {
public onUpdateActivity(aActivity: OrderModel) {
this.router.navigate([], {
queryParams: { editDialog: true, transactionId: aTransaction.id }
queryParams: { activityId: aActivity.id, editDialog: true }
});
}
public openUpdateTransactionDialog(activity: Activity): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
public openUpdateActivityDialog(activity: Activity): void {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: {
activity,
accounts: this.user?.accounts?.filter((account) => {
@ -312,41 +243,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private handleImportError({
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss();
this.dialog.open(ImportTransactionDialog, {
data: {
activities,
deviceType: this.deviceType,
messages: error?.error?.message
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
}
private handleImportSuccess() {
this.fetchActivities();
this.snackBar.open('✅' + $localize`Import has been completed`, undefined, {
duration: 3000
});
}
private openCreateTransactionDialog(aActivity?: Activity): void {
private openCreateActivityDialog(aActivity?: Activity): void {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: {
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
@ -405,6 +309,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
@ -433,11 +338,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
return account.isDefault;
})?.id;
this.hasPermissionToCreateOrder = hasPermission(
this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.hasPermissionToDeleteOrder = hasPermission(
this.hasPermissionToDeleteActivity = hasPermission(
this.user.permissions,
permissions.deleteOrder
);

View File

@ -6,14 +6,14 @@
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToImportActivities]="hasPermissionToImportOrders"
[hasPermissionToImportActivities]="hasPermissionToImportActivities"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteTransaction($event)"
(activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)"
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
@ -22,15 +22,15 @@
</div>
<div
*ngIf="!hasImpersonationId && hasPermissionToCreateOrder && !user.settings.isRestrictedView"
*ngIf="!hasImpersonationId && hasPermissionToCreateActivity && !user.settings.isRestrictedView"
class="fab-container"
>
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>

View File

@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { ActivitiesPageRoutingModule } from './activities-page-routing.module';
import { ActivitiesPageComponent } from './activities-page.component';
import { GfCreateOrUpdateActivityDialogModule } from './create-or-update-activity-dialog/create-or-update-activity-dialog.module';
import { GfImportActivitiesDialogModule } from './import-activities-dialog/import-activities-dialog.module';
@NgModule({
declarations: [ActivitiesPageComponent],
imports: [
ActivitiesPageRoutingModule,
CommonModule,
GfActivitiesTableModule,
GfCreateOrUpdateActivityDialogModule,
GfImportActivitiesDialogModule,
MatButtonModule,
MatSnackBarModule,
RouterModule
],
providers: [ImportActivitiesService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ActivitiesPageModule {}

View File

@ -14,6 +14,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator';
import { isString } from 'lodash';
@ -27,21 +28,25 @@ import {
takeUntil
} from 'rxjs/operators';
import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'h-100' },
selector: 'gf-create-or-update-transaction-dialog',
selector: 'gf-create-or-update-activity-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-transaction-dialog.scss'],
templateUrl: 'create-or-update-transaction-dialog.html'
styleUrls: ['./create-or-update-activity-dialog.scss'],
templateUrl: 'create-or-update-activity-dialog.html'
})
export class CreateOrUpdateTransactionDialog implements OnDestroy {
export class CreateOrUpdateActivityDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass);
public assetSubClasses = Object.keys(AssetSubClass);
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public currencies: string[] = [];
public currentMarketPrice = null;
public filteredLookupItems: LookupItem[];
@ -55,10 +60,10 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams,
private dataService: DataService,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
public dialogRef: MatDialogRef<CreateOrUpdateActivityDialog>,
private formBuilder: FormBuilder,
@Inject(MAT_DATE_LOCALE) private locale: string
) {}

View File

@ -4,17 +4,17 @@
(keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1>
<h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
<h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select formControlName="type">
<mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="ITEM" i18n>ITEM</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option>
<mat-option i18n value="BUY">BUY</mat-option>
<mat-option i18n value="DIVIDEND">DIVIDEND</mat-option>
<mat-option i18n value="ITEM">ITEM</mat-option>
<mat-option i18n value="SELL">SELL</mat-option>
</mat-select>
</mat-form-field>
</div>
@ -156,8 +156,8 @@
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass"
>{{ assetClass }}</mat-option
[value]="assetClass.id"
>{{ assetClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
@ -171,8 +171,8 @@
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass"
>{{ assetSubClass }}</mat-option
[value]="assetSubClass.id"
>{{ assetSubClass.label }}</mat-option
>
</mat-select>
</mat-form-field>

View File

@ -13,10 +13,10 @@ import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
@NgModule({
declarations: [CreateOrUpdateTransactionDialog],
declarations: [CreateOrUpdateActivityDialog],
imports: [
CommonModule,
GfSymbolModule,
@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfCreateOrUpdateTransactionDialogModule {}
export class GfCreateOrUpdateActivityDialogModule {}

View File

@ -2,7 +2,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { User } from '@ghostfolio/common/interfaces';
import { Account } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
export interface CreateOrUpdateActivityDialogParams {
accountId: string;
accounts: Account[];
activity: Activity;

View File

@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { isArray } from 'lodash';
import { Subject } from 'rxjs';
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-import-activities-dialog',
styleUrls: ['./import-activities-dialog.scss'],
templateUrl: 'import-activities-dialog.html'
})
export class ImportActivitiesDialog implements OnDestroy {
public details: any[] = [];
public errorMessages: string[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
private importActivitiesService: ImportActivitiesService,
private snackBar: MatSnackBar
) {}
public ngOnInit() {}
public onCancel(): void {
this.dialogRef.close();
}
public onImport() {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
input.onchange = (event) => {
this.snackBar.open('⏳ ' + $localize`Importing data...`);
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
console.log(fileContent);
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
await this.importActivitiesService.importJson({
content: content.activities
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
await this.importActivitiesService.importCsv({
fileContent,
userAccounts: this.data.user.accounts
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
}
};
};
input.click();
}
public onReset() {
this.details = [];
this.errorMessages = [];
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private handleImportError({
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss();
this.errorMessages = error?.error?.message;
for (const message of this.errorMessages) {
if (message.includes('activities.')) {
let [index] = message.split(' ');
index = index.replace('activities.', '');
[index] = index.split('.');
this.details.push(activities[index]);
} else {
this.details.push('');
}
}
this.changeDetectorRef.markForCheck();
}
private handleImportSuccess() {
this.snackBar.open(
'✅ ' + $localize`Import has been completed`,
undefined,
{
duration: 3000
}
);
this.dialogRef.close();
}
}

View File

@ -0,0 +1,71 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="errorMessages.length === 0 ? 'Import Activities' : 'Import Activities Error'"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<ng-container *ngIf="errorMessages.length === 0">
<div class="d-flex justify-content-center flex-column">
<button
class="py-3"
color="primary"
mat-stroked-button
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span>
</button>
<p class="mb-0 mt-4 text-center">
<span class="mr-1" i18n>The following file formats are supported:</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</p>
</div>
</ng-container>
<ng-container *ngIf="errorMessages.length > 0">
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="mt-2">
<button mat-button (click)="onReset()">
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
<span i18n>Back</span>
</button>
</div>
</ng-container>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-footer>

View File

@ -6,10 +6,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { ImportTransactionDialog } from './import-transaction-dialog.component';
import { ImportActivitiesDialog } from './import-activities-dialog.component';
@NgModule({
declarations: [ImportTransactionDialog],
declarations: [ImportActivitiesDialog],
imports: [
CommonModule,
GfDialogFooterModule,
@ -20,4 +20,4 @@ import { ImportTransactionDialog } from './import-transaction-dialog.component';
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfImportTransactionDialogModule {}
export class GfImportActivitiesDialogModule {}

View File

@ -1,6 +1,10 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
}
.mat-expansion-panel {
background: none;
box-shadow: none;

View File

@ -0,0 +1,6 @@
import { User } from '@ghostfolio/common/interfaces';
export interface ImportActivitiesDialogParams {
deviceType: string;
user: User;
}

View File

@ -19,6 +19,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
@ -174,7 +175,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
});
}
@ -450,6 +451,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(

View File

@ -18,7 +18,7 @@
>Proportion of Net Worth</mat-card-title
>
<gf-value
class="flex-grow-1 justify-content-end l-2"
class="justify-content-end l-2"
size="medium"
[isPercent]="true"
[value]="isLoading ? undefined : portfolioDetails?.filteredValueInPercentage"
@ -50,6 +50,7 @@
<gf-portfolio-proportion-chart
cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['id']"
[locale]="user?.settings?.locale"
@ -79,6 +80,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['currency']"
[locale]="user?.settings?.locale"
@ -107,6 +109,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['assetClass', 'assetSubClass']"
[locale]="user?.settings?.locale"
@ -133,6 +136,7 @@
class="mx-auto"
cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
@ -163,6 +167,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
@ -192,6 +197,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
@ -220,6 +226,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
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';
@ -26,6 +27,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[];
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public daysInMarket: number;
public deviceType: string;
public firstOrderDate: Date;
@ -33,12 +35,13 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public investments: InvestmentItem[];
public investmentsByMonth: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean;
public mode: GroupBy;
public isLoadingInvestmentChart: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' },
{ label: $localize`Accumulating`, value: undefined }
{ label: $localize`Monthly`, value: 'month' }
];
public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public top3: Position[];
public user: User;
@ -122,37 +125,51 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}
private update() {
if (this.user.settings.isExperimentalFeatures) {
this.isLoadingBenchmarkComparator = true;
this.dataService
.fetchPortfolioPerformance({
range: this.user?.settings?.dateRange,
version: 2
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.firstOrderDate = new Date(chart?.[0]?.date ?? new Date());
this.performanceDataItems = chart;
this.updateBenchmarkDataItems();
this.changeDetectorRef.markForCheck();
});
}
this.isLoadingBenchmarkComparator = true;
this.isLoadingInvestmentChart = true;
this.dataService
.fetchInvestments()
.fetchPortfolioPerformance({
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ firstOrderDate, investments }) => {
.subscribe(({ chart, firstOrderDate }) => {
this.firstOrderDate = firstOrderDate ?? new Date();
this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
this.investments = investments;
this.investments = [];
this.performanceDataItems = [];
this.performanceDataItemsInPercentage = [];
for (const {
date,
netPerformanceInPercentage,
totalInvestment,
value
} of chart) {
this.investments.push({ date, investment: totalInvestment });
this.performanceDataItems.push({
date,
value
});
this.performanceDataItemsInPercentage.push({
date,
value: netPerformanceInPercentage
});
}
this.isLoadingInvestmentChart = false;
this.updateBenchmarkDataItems();
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchInvestmentsByMonth()
.fetchInvestments({
groupBy: 'month',
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ investments }) => {
this.investmentsByMonth = investments;
@ -161,7 +178,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
});
this.dataService
.fetchPositions({ range: 'max' })
.fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
const positionsSorted = sortBy(
@ -210,6 +227,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
} else {
this.benchmarkDataItems = [];
this.isLoadingBenchmarkComparator = false;
}
}

View File

@ -1,19 +1,27 @@
<div class="container">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<div *ngIf="user?.settings?.isExperimentalFeatures" class="mb-5 row">
<h3 class="d-flex justify-content-center" i18n>Analysis</h3>
<div *ngIf="user?.settings?.viewMode !== 'ZEN'" class="my-4 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingBenchmarkComparator"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="mb-5 row">
<div class="col-lg">
<gf-benchmark-comparator
class="h-100"
[benchmark]="user?.settings?.benchmark"
[benchmarkDataItems]="benchmarkDataItems"
[benchmarks]="benchmarks"
[colorScheme]="user?.settings?.colorScheme"
[daysInMarket]="daysInMarket"
[isLoading]="isLoadingBenchmarkComparator"
[locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItems"
[performanceDataItems]="performanceDataItemsInPercentage"
[user]="user"
(benchmarkChanged)="onChangeBenchmark($event)"
(dateRangeChanged)="onChangeDateRange($event)"
></gf-benchmark-comparator>
</div>
</div>
@ -96,6 +104,35 @@
</div>
</div>
<div class="mb-5 row">
<div class="col-lg">
<div class="align-items-center d-flex mb-4">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
>
<span i18n>Portfolio Evolution</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
[benchmarkDataItems]="investments"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[historicalDataItems]="performanceDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingBenchmarkComparator"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
></gf-investment-chart>
</div>
</div>
</div>
<div class="row">
<div class="col-lg">
<div class="align-items-center d-flex mb-4">
@ -117,24 +154,15 @@
></gf-toggle>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': mode }"
></gf-investment-chart>
<gf-investment-chart
class="h-100"
groupBy="month"
[benchmarkDataItems]="investmentsByMonth"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investmentsByMonth"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': !mode }"
[range]="user?.settings?.dateRange"
[savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate"
></gf-investment-chart>
</div>

View File

@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -15,8 +15,12 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './fire-page.html'
})
export class FirePageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public deviceType: string;
public feeRules: PortfolioReportRule[];
public fireWealth: Big;
public hasPermissionToCreateOrder: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false;
public user: User;
@ -53,12 +57,30 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk'] || null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk'] || null;
this.feeRules = portfolioReport.rules['fees'] || null;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings

View File

@ -3,8 +3,15 @@
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div>
<h4 class="mb-3" i18n>Calculator</h4>
<h4 class="align-items-center d-flex mb-3">
<span i18n>Calculator</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-fire-calculator
[colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()"
@ -17,7 +24,13 @@
</div>
</div>
<div>
<h4 i18n>4% Rule</h4>
<h4 class="align-items-center d-flex">
<span i18n>4% Rule</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div *ngIf="isLoading">
<ngx-skeleton-loader
animation="pulse"
@ -35,7 +48,7 @@
}"
></ngx-skeleton-loader>
</div>
<div *ngIf="!isLoading">
<div *ngIf="!isLoading" i18n>
If you retire today, you would be able to withdraw
<span class="font-weight-bold"
><gf-value
@ -66,3 +79,61 @@
</div>
</div>
</div>
<div class="container mt-5">
<div class="row">
<div class="col">
<h3 class="align-items-center d-flex justify-content-center mb-3">
X-ray
</h3>
<p class="mb-4">
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
<span class="d-none"
>It will be highly configurable in the future: activate / deactivate
rules and customize the thresholds to match your personal investment
style.</span
>
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span>Currency Cluster Risks</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="currencyClusterRiskRules"
></gf-rules>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span>Account Cluster Risks</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="accountClusterRiskRules"
></gf-rules>
</div>
<div>
<h4 class="align-items-center d-flex m-0">
<span>Fees</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="feeRules"
></gf-rules>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,8 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -13,6 +15,8 @@ import { FirePageComponent } from './fire-page.component';
CommonModule,
FirePageRoutingModule,
GfFireCalculatorModule,
GfPremiumIndicatorModule,
GfRulesModule,
GfValueModule,
NgxSkeletonLoaderModule
],

View File

@ -13,6 +13,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
@ -130,7 +131,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
});
}
@ -192,6 +193,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(

View File

@ -7,9 +7,45 @@ import { PortfolioPageComponent } from './portfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'analysis', pathMatch: 'full' },
{
path: 'analysis',
loadChildren: () =>
import('./analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'holdings',
loadChildren: () =>
import('./holdings/holdings-page.module').then(
(m) => m.HoldingsPageModule
)
},
{
path: 'activities',
loadChildren: () =>
import('./activities/activities-page.module').then(
(m) => m.ActivitiesPageModule
)
},
{
path: 'allocations',
loadChildren: () =>
import('./allocations/allocations-page.module').then(
(m) => m.AllocationsPageModule
)
},
{
path: 'fire',
loadChildren: () =>
import('./fire/fire-page.module').then((m) => m.FirePageModule)
}
],
component: PortfolioPageComponent,
path: '',
title: 'Portfolio'
title: $localize`Portfolio`
}
];

View File

@ -1,19 +1,30 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
selector: 'gf-portfolio-page',
styleUrls: ['./portfolio-page.scss'],
templateUrl: './portfolio-page.html'
})
export class PortfolioPageComponent implements OnDestroy, OnInit {
public hasPermissionForSubscription: boolean;
@HostBinding('class.with-info-message') get getHasMessage() {
return this.hasMessage;
}
public hasMessage: boolean;
public info: InfoItem;
public tabs: { iconName: string; path: string }[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -23,26 +34,34 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
const { globalPermissions } = this.dataService.fetchInfo();
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.tabs = [
{ iconName: 'analytics-outline', path: 'analysis' },
{ iconName: 'wallet-outline', path: 'holdings' },
{ iconName: 'swap-horizontal-outline', path: 'activities' },
{ iconName: 'pie-chart-outline', path: 'allocations' },
{ iconName: 'calculator-outline', path: 'fire' }
];
this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -1,134 +1,15 @@
<div class="container">
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
<div class="row">
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 i18n>Holdings</h4>
<div class="flex-grow-1" i18n>
Get an overview of your current holdings.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
[routerLink]="['/portfolio', 'holdings']"
>
<span i18n>Open Holdings</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 i18n>Activities</h4>
<div class="flex-grow-1" i18n>
Manage your activities: stocks, ETFs, cryptocurrencies, dividend, and
valuables.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
[routerLink]="['/portfolio', 'activities']"
>
<span i18n>Open Activities</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>Allocations</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n>
Check the allocations of your portfolio by account, asset class,
currency, sector and region.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
[routerLink]="['/portfolio', 'allocations']"
>
<span i18n>Open Allocations</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>Analysis</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n>
Ghostfolio Analysis visualizes your portfolio and shows your top and
bottom performers.
</div>
<div class="mt-2 text-right">
<a
color="primary"
mat-button
[routerLink]="['/portfolio', 'analysis']"
>
<span i18n>Open Analysis</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span>X-ray</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n>
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
</div>
<div class="mt-2 text-right">
<a color="primary" mat-button [routerLink]="['/portfolio', 'report']">
<span i18n>Open X-ray</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>FIRE</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<div class="flex-grow-1" i18n>
Ghostfolio FIRE calculates metrics for the
<i>Financial Independence, Retire Early</i> lifestyle.
</div>
<div class="mt-2 text-right">
<a color="primary" mat-button [routerLink]="['/portfolio', 'fire']">
<span i18n>Open FIRE</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
</div>
</div>
<router-outlet></router-outlet>
<nav mat-align-tabs="center" mat-tab-nav-bar>
<a
#rla="routerLinkActive"
*ngFor="let tab of tabs"
class="px-3"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.path"
>
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
</a>
</nav>

View File

@ -1,9 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { PortfolioPageRoutingModule } from './portfolio-page-routing.module';
import { PortfolioPageComponent } from './portfolio-page.component';
@ -12,9 +10,7 @@ import { PortfolioPageComponent } from './portfolio-page.component';
declarations: [PortfolioPageComponent],
imports: [
CommonModule,
GfPremiumIndicatorModule,
MatButtonModule,
MatCardModule,
MatTabsModule,
PortfolioPageRoutingModule,
RouterModule
],

View File

@ -1,10 +1,46 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
color: rgb(var(--dark-primary-text));
display: block;
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
.mat-card {
.mat-button-disabled {
pointer-events: none;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
gf-activities-page,
gf-allocations-page,
gf-analysis-page,
gf-holdings-page,
gf-fire-page {
flex: 1 1 auto;
overflow-y: auto;
}
.mat-tab-header {
border-bottom: 0;
.mat-ink-bar {
visibility: hidden !important;
}
.mat-tab-label-active {
color: rgba(var(--palette-primary-500), 1);
opacity: 1;
}
.mat-tab-link {
&:hover {
opacity: 0.75;
}
@media (max-width: 599px) {
min-width: unset;
}
}
}
}
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ReportPageComponent } from './report-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: ReportPageComponent,
path: '',
title: 'X-ray'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ReportPageRoutingModule {}

View File

@ -1,64 +0,0 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
selector: 'gf-report-page',
styleUrls: ['./report-page.scss'],
templateUrl: './report-page.html'
})
export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public hasPermissionToCreateOrder: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {}
public ngOnInit() {
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk'] || null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk'] || null;
this.feeRules = portfolioReport.rules['fees'] || null;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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