Compare commits

..

87 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
f4b63b5de5 Release 1.201.0 (#1313) 2022-10-01 18:39:15 +02:00
e45a0ad068 Spanish (#1312)
* Update messages.es.xlf
2022-10-01 18:36:41 +02:00
81c6cc021d Feature/add blog post hacktoberfest 2022 (#1310)
* Add blog post: Hacktoberfest 2022

* Update changelog
2022-10-01 18:35:55 +02:00
859b24aa5b Fix alignment (#1311) 2022-10-01 18:35:27 +02:00
2bc325f182 Update messages.es.xlf (#1305)
* Update messages.es.xlf

Co-authored-by: fdp10381 <63880387+fdp10381@users.noreply.github.com>
2022-10-01 16:45:44 +02:00
a6186c23e2 Feature/improve usage of value component (#1308)
* Improve usage of value component

* Update changelog
2022-10-01 13:53:43 +02:00
162 changed files with 8916 additions and 4770 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,156 @@ 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
- Added a blog post: _Hacktoberfest 2022_
### Changed
- Improved the usage of the value component in the admin control panel
- Improved the language localization for Español (`es`)
### Fixed
- Fixed the usage of the value component on the allocations page
## 1.200.0 - 01.10.2022
### Added
@ -902,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

@ -58,6 +58,11 @@ export class FrontendMiddleware implements NestMiddleware {
req.path === '/en/blog/2022/08/500-stars-on-github/'
) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
} else if (
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
}
if (

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

@ -95,6 +95,13 @@ const routes: Routes = [
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{
path: 'blog/2022/10/hacktoberfest-2022',
loadChildren: () =>
import(
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
).then((m) => m.Hacktoberfest2022PageModule)
},
{
path: 'demo',
loadChildren: () =>
@ -138,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

@ -5,61 +5,25 @@
<mat-card-content>
<div class="d-flex my-3">
<div class="w-50" i18n>User Count</div>
<div class="w-50">{{ userCount }}</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[value]="userCount"
></gf-value>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Activity Count</div>
<div class="w-50">
<ng-container *ngIf="transactionCount">
{{ transactionCount }} ({{ transactionCount / userCount | number
: '1.2-2' }} <span i18n>per User</span>)
</ng-container>
</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>
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[value]="transactionCount"
></gf-value>
<div *ngIf="transactionCount && userCount">
{{ transactionCount / userCount | number : '1.2-2' }}
<span i18n>per User</span>
</div>
</div>
</div>
@ -108,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"
@ -122,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="align-items-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="align-items-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="align-items-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

@ -2,14 +2,14 @@ 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';
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: ReportPageComponent,
component: Hacktoberfest2022PageComponent,
path: '',
title: 'X-ray'
title: 'Hacktoberfest 2022'
}
];
@ -17,4 +17,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ReportPageRoutingModule {}
export class Hacktoberfest2022RoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-hacktoberfest-2022-page',
styleUrls: ['./hacktoberfest-2022-page.scss'],
templateUrl: './hacktoberfest-2022-page.html'
})
export class Hacktoberfest2022PageComponent {}

View File

@ -0,0 +1,178 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Hacktoberfest 2022</h1>
<div class="mb-3 text-muted"><small>2022-10-01</small></div>
<img
alt="Hacktoberfest 2022 with Ghostfolio Teaser"
class="rounded w-100"
src="../assets/images/blog/hacktoberfest-2022.png"
title="Hacktoberfest 2022 with Ghostfolio"
/>
</div>
<section class="mb-4">
<p>
We are very excited to join
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the first
time with <a href="https://ghostfol.io">Ghostfolio</a> and meet new
and ambitious open-source contributors. Hacktoberfest is a
month-long celebration of open-source projects, their maintainers,
and the entire community of contributors. Each October, open source
maintainers from all over the world give extra attention to new
contributors while guiding them through their first pull requests on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">About Ghostfolio</h2>
<p>
Ghostfolio is a modern web application to manage your personal
finance. The software presents the current assets in real time and
supports the decision making of future investments. Whether
rebalancing the asset classes (stocks, ETFs, cryptocurrencies, etc.)
of your portfolio or financing an apartment, Ghostfolio offers
solid, data-driven decision support.
</p>
<p>
Ghostfolio is written in
<a href="https://www.typescriptlang.org">TypeScript</a> and
organized as an <a href="https://nx.dev">Nx</a> workspace. The
backend is based on <a href="https://nestjs.com">NestJS</a> using
<a href="https://www.postgresql.org">PostgreSQL</a> as a database
together with <a href="https://www.prisma.io">Prisma</a> and
<a href="https://redis.io">Redis</a> for caching. The frontend is
built with <a href="https://angular.io">Angular</a>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to contribute?</h2>
<p>
Every contribution matters. This can be implementing new features,
fixing bugs, refactoring the code, improving the documentation,
adding more unit tests, or translating into another language.
</p>
<p>
Are you not yet familiar with our code base? That is not a problem.
We have applied the label <code>hacktoberfest</code> to a few
<a
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest"
>issues</a
>
and
<a
href="https://github.com/ghostfolio/ghostfolio/discussions?discussions_q=label%3Ahacktoberfest"
>ideas</a
>
that are well suited for newcomers.
</p>
<p>
The official Hacktoberfest website provides some valuable
<a
href="https://hacktoberfest.com/participation/#beginner-resources"
>resources for beginners</a
>
to start contributing in open source.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Get support</h2>
<p>
If you have further questions or ideas, please join our growing
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
touch on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
email via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
</p>
<p>
We look forward to hearing from you.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">GitHub</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hacktoberfest</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">NestJS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">October</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">TypeScript</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Hacktoberfest2022RoutingModule } from './hacktoberfest-2022-page-routing.module';
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
@NgModule({
declarations: [Hacktoberfest2022PageComponent],
imports: [CommonModule, Hacktoberfest2022RoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Hacktoberfest2022PageModule {}

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -2,6 +2,30 @@
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Blog</h3>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
href="../en/blog/2022/10/hacktoberfest-2022"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hacktoberfest 2022</div>
<div class="d-flex text-muted">2022-10-01</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">

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

@ -13,12 +13,12 @@
<div class="row">
<div class="col">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
<mat-card-header class="mb-2 overflow-hidden w-100">
<mat-card-title class="m-0 text-truncate" i18n
>Proportion of Net Worth</mat-card-title
>
<gf-value
class="align-items-end flex-grow-1 ml-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(

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