Compare commits

..

87 Commits

Author SHA1 Message Date
1ce90a0c06 Release 1.216.0 (#1492) 2022-12-03 19:19:10 +01:00
50f6d154e5 Feature/extend sorting in tables (#1491)
* Extend sorting

* Update changelog
2022-12-03 19:17:30 +01:00
e4c44faee4 Fix sorting by performance field in positions table (#1489) 2022-12-03 18:25:53 +01:00
5209f82cca Feature/upgrade replace in file to version 6.3.5 (#1486)
* Upgrade replace-in-file to version 6.3.5

* Update changelog
2022-12-03 18:23:40 +01:00
292d345ce0 Feature/support manual currency for fee (#1490)
* Support manual currency for fee

* Update changelog
2022-12-03 18:22:19 +01:00
d58400788a Feature/upgrade big.js to version 6.2.1 (#1488)
* Upgrade big.js to version 6.2.1

* Update changelog
2022-12-02 17:56:11 +01:00
7ff61ae839 Feature/upgrade date fns to version 2.29.3 (#1487)
* Upgrade date-fns to version 2.29.3

* Update changelog
2022-12-01 17:14:45 +01:00
b5b7af7741 Feature/improve asset profile management (#1485)
* Improve asset profile management (Add note, fix filter)

* Update changelog
2022-11-30 20:01:17 +01:00
de3e0fad83 Remove link (#1484) 2022-11-29 23:19:07 +01:00
8c8273c4d4 Release 1.215.0 (#1483) 2022-11-27 10:34:17 +01:00
b406bcd17d Feature/update browserslist database 20221127 (#1482)
* Update browserslist database

* Update changelog
2022-11-27 10:32:22 +01:00
fb496431e8 Clean up (#1481) 2022-11-27 10:21:21 +01:00
441b251536 Setup form (#1474)
* Setup form to patch asset profile (for symbolMapping)
2022-11-27 10:19:34 +01:00
1dbb5db611 Feature/improve wording in single account rule (#1479)
* Improve wording

* Update changelog
2022-11-26 08:53:01 +01:00
8567efcd89 Feature/upgrade ionicons to version 6.0.4 (#1478)
* Upgrade ionicons to version 6.0.4

* Update changelog
2022-11-25 20:49:26 +01:00
1cda5dcc0a Feature/upgrade UUID to version 9.0.0 (#1476)
* Upgrade uuid to version 9.0.0

* Update changelog
2022-11-24 20:33:29 +01:00
3fb01c6dcf Added yarn cache to .gitignore (#1477)
* Added yarn cache (.yarn) to .gitignore
2022-11-24 11:46:26 +01:00
6a764fe893 Convert between ZAc and ZAR (#1471)
* Convert between ZAc and ZAR

* Update changelog
2022-11-22 20:18:38 +01:00
d2b75a244c Feature/improve language selector (#1466)
* Improve language selector

* Update changelog
2022-11-21 20:39:52 +01:00
3611684f17 Feature/extend asset profile details dialog (#1469)
* Extend asset profile details dialog

* Update changelog
2022-11-21 20:14:36 +01:00
4b74be50da Release 1.214.0 (#1465) 2022-11-19 12:15:22 +01:00
0d338bb083 Bugfix/fix dynamic decimal places for cryptocurrencies in position detail dialog (#1464)
* Fix dynamic decimal places

* Update changelog
2022-11-19 12:13:02 +01:00
b0d708fb82 Feature/change activities icons (#1463)
* Improve icons

* Update changelog
2022-11-19 11:28:26 +01:00
be14458437 Bugfix/fix division by zero error in cash positions calculation (#1462)
* Handle division by zero

* Update changelog
2022-11-19 10:19:01 +01:00
5978ddb80f Feature/improve support for manual data source (#1460)
* Improve support for MANUAL data source

* Update changelog
2022-11-19 10:06:05 +01:00
18638dd1b7 Bugfix/Fix matsort not working in position detail dialog (#1457)
* Fix matsort in position detail dialog

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-11-19 10:02:36 +01:00
81db3852e6 Feature/add sorting to accounts table (#1459)
* Add sorting

* Update changelog
2022-11-19 09:57:28 +01:00
af27781234 Feature/upgrade prisma to version 4.6.1 (#1456)
* Upgrade prisma to version 4.6.1

* Update changelog
2022-11-18 20:19:14 +01:00
608e7a774d Feature/improve activities tab icon (#1461)
* Improve icon

* Update changelog
2022-11-17 21:37:26 +01:00
ed15eb76fd Feature/upgrade yahoo finance2 to version 2.3.10 (#1458)
* Upgrade yahoo-finance2 to version 2.3.10

* Update changelog
2022-11-16 19:33:07 +01:00
39905e5046 Feature/upgrade yahoo finance2 to version 2.3.7 (#1455)
* Upgrade yahoo-finance2 to version 2.3.7

* Update changelog
2022-11-15 21:33:22 +01:00
7cd3f235df Release 1.213.0 (#1454) 2022-11-14 20:47:21 +01:00
3b4f8c69bb Feature/setup black friday 2022 deal (#1452)
* Setup Black Friday 2022 deal

* Update changelog
2022-11-14 20:45:41 +01:00
c9bdf46b2b Add PWA as feature (#1445) 2022-11-12 17:21:37 +01:00
4169de580b Feature/add indicator for excluded accounts (#1450)
* Add indicator for excluded accounts

* Update changelog
2022-11-12 16:46:17 +01:00
3317fe7c46 Fix missing comma in json body (API example) (#1446) 2022-11-12 08:59:55 +01:00
c8f6fdbaa3 Release 1.212.0 (#1444) 2022-11-11 20:02:20 +01:00
d95fc82f95 Feature/change view mode selector to slide toggle (#1443)
* Change view mode selector to slide toggle

* Update changelog
2022-11-11 20:01:08 +01:00
31c949f9d2 Feature/upgrade nx to version 15.0.13 (#1442)
* Upgrade Nx to version 15.0.13

* Update changelog
2022-11-11 19:44:32 +01:00
f68f40fcc6 Release 1.211.0 (#1441) 2022-11-11 16:53:36 +01:00
9623a363ed Feature/convert into pwa (#1436)
* Setup @angular/pwa
2022-11-11 16:50:23 +01:00
2d42549967 Feature/improvements in pricing page (#1440)
* Improve pricing page

* Update changelog
2022-11-11 16:42:51 +01:00
c934c5088b Add ghostfolio-cli to community projects (#1437) 2022-11-11 09:02:43 +01:00
678b3cc57e Add Buy me a coffee button (#1438) 2022-11-10 19:31:00 +01:00
cd5eb64a4c Removed margin-bottom tag from the body element (#1435)
- This only changes the behaviour in Firefox
- All browsers now show the UI in a single page view
2022-11-09 20:48:13 +01:00
fc1507de4f Clean up (#1423) 2022-11-09 10:08:36 +01:00
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
152 changed files with 11818 additions and 6785 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
/tmp /tmp
# dependencies # dependencies
/.yarn
/node_modules /node_modules
# IDEs and editors # IDEs and editors

4
.vscode/launch.json vendored
View File

@ -5,11 +5,11 @@
"name": "Debug Jest File", "name": "Debug Jest File",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng", "program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"args": [ "args": [
"test", "test",
"--codeCoverage=false", "--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}", "cwd": "${workspaceFolder}",
"console": "internalConsole" "console": "internalConsole"

View File

@ -5,13 +5,160 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.205.0 - 16.10.2022 ## 1.216.0 - 2022-12-03
### Added
- Supported a note for asset profiles
- Supported a manual currency for the activity fee
- Extended the support for column sorting in the accounts table (name, platform, transactions)
- Extended the support for column sorting in the activities table (name, symbol)
- Extended the support for column sorting in the positions table (performance)
### Changed
- Upgraded `big.js` from version `6.1.1` to `6.2.1`
- Upgraded `date-fns` from version `2.28.0` to `2.29.3`
- Upgraded `replace-in-file` from version `6.2.0` to `6.3.5`
### Fixed
- Fixed the filter by asset sub class for the asset profiles in the admin control
## 1.215.0 - 2022-11-27
### Changed
- Improved the language selector on the account page
- Improved the wording in the _X-ray_ section (net worth instead of investment)
- Extended the asset profile details dialog in the admin control panel
- Updated the browserslist database
- Upgraded `ionicons` from version `5.5.1` to `6.0.4`
- Upgraded `uuid` from version `8.3.2` to `9.0.0`
## 1.214.0 - 19.11.2022
### Added
- Added support for sorting in the accounts table
### Changed
- Improved the support for the `MANUAL` data source
- Improved the _Activities_ tab icon
- Improved the _Activities_ icons for `BUY`, `DIVIDEND` and `SELL`
- Upgraded `prisma` from version `4.4.0` to `4.6.1`
- Upgraded `yahoo-finance2` from version `2.3.6` to `2.3.10`
### Fixed
- Fixed the activities sorting in the position detail dialog
- Fixed the dynamic number of decimal places for cryptocurrencies in the position detail dialog
- Fixed a division by zero error in the cash positions calculation
## 1.213.0 - 14.11.2022
### Added
- Added an indicator for excluded accounts in the accounts table
- Added a blog post: _Black Friday 2022_
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ZAc` to `ZAR`)
## 1.212.0 - 11.11.2022
### Changed
- Changed the view mode selector to a slide toggle
- Upgraded `Nx` from version `15.0.0` to `15.0.13`
## 1.211.0 - 11.11.2022
### Changed
- Converted the client into a _Progressive Web App_ (PWA) with `@angular/pwa`
- Removed the bottom margin from the body element
- Improved the pricing page
## 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 ### Changed
- Persisted the language on url change - Persisted the language on url change
- Improved the portfolio evolution chart - Improved the portfolio evolution chart
- Removed the data source type `RAKUTEN`
- Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`) - Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`)
- Improved the wording on the landing page - Improved the wording on the landing page
@ -411,7 +558,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Support a note for activities - Supported a note for activities
### Todo ### Todo

View File

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

View File

@ -53,13 +53,13 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions
- ✅ Dark Mode - ✅ Dark Mode
- ✅ Zen Mode - ✅ Zen Mode
-Mobile-first design -Progressive Web App (PWA) with a mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;"> <div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300"> <img src="./apps/client/src/assets/images/screenshot.png" width="300">
@ -81,12 +81,22 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`. We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
<div align="center">
<a href="https://www.buymeacoffee.com/ghostfolio">
<img
alt="Buy me a coffee button"
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
width="150"
/>
</a>
</div>
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | | `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! | | `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` | | `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 | | `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) | | `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
@ -128,7 +138,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: 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. 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_ 1. Click _Sign out_ and check out the _Live Demo_
#### Upgrade Version #### Upgrade Version
@ -158,7 +168,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. 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. 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. 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_ 1. Click _Sign out_ and check out the _Live Demo_
### Start Server ### Start Server
@ -190,20 +200,22 @@ Run `yarn test`
## Public API ## 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 ### Import Activities
#### Request #### Request
`POST http://localhost:3333/api/v1/import` `POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body #### Body
``` ```
@ -215,7 +227,7 @@ Set the header as follows:
"date": "2021-09-15T00:00:00.000Z", "date": "2021-09-15T00:00:00.000Z",
"fee": 19, "fee": 19,
"quantity": 5, "quantity": 5,
"symbol": "MSFT" "symbol": "MSFT",
"type": "BUY", "type": "BUY",
"unitPrice": 298.58 "unitPrice": 298.58
} }
@ -254,6 +266,10 @@ Set the header as follows:
} }
``` ```
## Community Projects
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.

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"]
}
}
}

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

@ -0,0 +1,57 @@
{
"name": "api",
"$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

@ -9,6 +9,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -21,6 +22,7 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
Patch,
Post, Post,
Put, Put,
Query, Query,
@ -33,6 +35,7 @@ import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateMarketDataDto } from './update-market-data.dto'; import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
@ -332,6 +335,32 @@ export class AdminController {
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
});
}
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateProperty( public async updateProperty(

View File

@ -116,6 +116,7 @@ export class AdminService {
}, },
assetClass: true, assetClass: true,
assetSubClass: true, assetSubClass: true,
comment: true,
countries: true, countries: true,
dataSource: true, dataSource: true,
Order: { Order: {
@ -147,9 +148,10 @@ export class AdminService {
countriesCount, countriesCount,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activityCount: symbolProfile._count.Order, activitiesCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
comment: symbolProfile.comment,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date, date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol symbol: symbolProfile.symbol
@ -165,8 +167,14 @@ export class AdminService {
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> { }: UniqueAsset): Promise<AdminMarketDataDetails> {
return { const [[assetProfile], marketData] = await Promise.all([
marketData: await this.marketDataService.marketDataItems({ this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
}, },
@ -175,9 +183,37 @@ export class AdminService {
symbol symbol
} }
}) })
]);
return {
assetProfile,
marketData
}; };
} }
public async patchAssetProfileData({
comment,
dataSource,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({
comment,
dataSource,
symbol,
symbolMapping
});
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
return symbolProfile;
}
public async putSetting(key: string, value: string) { public async putSetting(key: string, value: string) {
let response: Property; let response: Property;

View File

@ -0,0 +1,13 @@
import { IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto {
@IsString()
@IsOptional()
comment?: string;
@IsObject()
@IsOptional()
symbolMapping?: {
[dataProvider: string]: string;
};
}

View File

@ -22,6 +22,7 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware'; import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
@ -52,6 +53,7 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
ImportModule, ImportModule,

View File

@ -0,0 +1,26 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ExchangeRateService } from './exchange-rate.service';
@Controller('exchange-rate')
export class ExchangeRateController {
public constructor(
private readonly exchangeRateService: ExchangeRateService
) {}
@Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async getExchangeRate(
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
return this.exchangeRateService.getExchangeRate({
date,
symbol
});
}
}

View File

@ -0,0 +1,13 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller';
import { ExchangeRateService } from './exchange-rate.service';
@Module({
controllers: [ExchangeRateController],
exports: [ExchangeRateService],
imports: [ExchangeRateDataModule],
providers: [ExchangeRateService]
})
export class ExchangeRateModule {}

View File

@ -0,0 +1,29 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExchangeRateService {
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {}
public async getExchangeRate({
date,
symbol
}: {
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const [currency1, currency2] = symbol.split('-');
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
1,
currency1,
currency2,
date
);
return { marketPrice };
}
}

View File

@ -53,16 +53,12 @@ export class FrontendMiddleware implements NestMiddleware {
public use(req: Request, res: Response, next: NextFunction) { public use(req: Request, res: Response, next: NextFunction) {
let featureGraphicPath = 'assets/cover.png'; let featureGraphicPath = 'assets/cover.png';
if ( if (req.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
req.path === '/en/blog/2022/08/500-stars-on-github' ||
req.path === '/en/blog/2022/08/500-stars-on-github/'
) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
} else if ( } else if (req.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
} else if (req.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
} }
if ( if (

View File

@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; 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': case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) { if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 }; return { marketPrice: 87.8 };

View File

@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);

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', () => { 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({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', 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

@ -234,11 +234,17 @@ export class PortfolioCalculator {
[symbol: string]: { [date: string]: Big }; [symbol: string]: { [date: string]: Big };
} = {}; } = {};
const maxInvestmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {}; const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {}; const totalInvestmentValues: { [date: string]: Big } = {};
const maxTotalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) { for (const symbol of Object.keys(symbols)) {
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({ const { investmentValues, maxInvestmentValues, netPerformanceValues } =
this.getSymbolMetrics({
end, end,
marketSymbolMap, marketSymbolMap,
start, start,
@ -249,6 +255,7 @@ export class PortfolioCalculator {
netPerformanceValuesBySymbol[symbol] = netPerformanceValues; netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues; investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
} }
for (const currentDate of dates) { for (const currentDate of dates) {
@ -267,19 +274,28 @@ export class PortfolioCalculator {
totalInvestmentValues[dateString] = totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0); totalInvestmentValues[dateString] ?? new Big(0);
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) { if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[ totalInvestmentValues[dateString] = totalInvestmentValues[
dateString dateString
].add(investmentValuesBySymbol[symbol][dateString]); ].add(investmentValuesBySymbol[symbol][dateString]);
} }
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
dateString
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
}
} }
} }
return Object.keys(totalNetPerformanceValues).map((date) => { return Object.keys(totalNetPerformanceValues).map((date) => {
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0) const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
? 0 ? 0
: totalNetPerformanceValues[date] : totalNetPerformanceValues[date]
.div(totalInvestmentValues[date]) .div(maxTotalInvestmentValues[date])
.mul(100) .mul(100)
.toNumber(); .toNumber();
@ -899,13 +915,10 @@ export class PortfolioCalculator {
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big; let investmentAtStartDate: Big;
const investmentValues: { [date: string]: Big } = {}; const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0); let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {}; const netPerformanceValues: { [date: string]: Big } = {};
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0); let totalUnits = new Big(0);
@ -1000,6 +1013,12 @@ export class PortfolioCalculator {
for (let i = 0; i < orders.length; i += 1) { for (let i = 0; i < orders.length; i += 1) {
const order = orders[i]; const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(i + 1, order.type, order.itemType);
}
if (order.itemType === 'start') { if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no // Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date // orders of this symbol before the start date
@ -1027,9 +1046,21 @@ export class PortfolioCalculator {
valueAtStartDate = valueOfInvestmentBeforeTransaction; valueAtStartDate = valueOfInvestmentBeforeTransaction;
} }
const transactionInvestment = order.quantity const transactionInvestment =
.mul(order.unitPrice) order.type === 'BUY'
.mul(this.getFactor(order.type)); ? 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); totalInvestment = totalInvestment.plus(transactionInvestment);
@ -1078,59 +1109,23 @@ export class PortfolioCalculator {
? new Big(0) ? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
const newGrossPerformance = valueOfInvestment if (PortfolioCalculator.ENABLE_LOGGING) {
.minus(totalInvestmentWithGrossPerformanceFromSell) console.log(
.plus(grossPerformanceFromSells); 'totalInvestmentWithGrossPerformanceFromSell',
totalInvestmentWithGrossPerformanceFromSell.toNumber()
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
); );
console.log(
timeWeightedGrossPerformancePercentage = 'grossPerformanceFromSells',
timeWeightedGrossPerformancePercentage.mul( grossPerformanceFromSells.toNumber()
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
); );
} }
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
grossPerformance = newGrossPerformance; grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') { if (order.itemType === 'start') {
feesAtStartDate = fees; feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance; grossPerformanceAtStartDate = grossPerformance;
@ -1142,6 +1137,15 @@ export class PortfolioCalculator {
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment; 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) { if (i === indexOfEndOrder) {
@ -1149,12 +1153,6 @@ export class PortfolioCalculator {
} }
} }
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus( const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate grossPerformanceAtStartDate
); );
@ -1218,6 +1216,7 @@ export class PortfolioCalculator {
Average price: ${averagePriceAtStartDate.toFixed( Average price: ${averagePriceAtStartDate.toFixed(
2 2
)} -> ${averagePriceAtEndDate.toFixed(2)} )} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)} Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed( Gross performance: ${totalGrossPerformance.toFixed(
2 2
@ -1233,6 +1232,7 @@ export class PortfolioCalculator {
initialValue, initialValue,
grossPerformancePercentage, grossPerformancePercentage,
investmentValues, investmentValues,
maxInvestmentValues,
netPerformancePercentage, netPerformancePercentage,
netPerformanceValues, netPerformanceValues,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),

View File

@ -10,7 +10,6 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
@ -72,8 +71,13 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasDetails = true;
let hasError = false; let hasError = false;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -134,7 +138,13 @@ export class PortfolioController {
accounts[name].current = current / totalValue; accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment; accounts[name].original = original / totalInvestment;
} }
}
if (
hasDetails === false ||
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds', 'committedFunds',
@ -152,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)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
@ -176,7 +181,7 @@ export class PortfolioController {
hasError, hasError,
holdings, holdings,
totalValueInBaseCurrency, totalValueInBaseCurrency,
summary: hasDetails ? portfolioSummary : undefined summary: portfolioSummary
}; };
} }
@ -187,16 +192,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy @Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> { ): 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[]; let investments: InvestmentItem[];
if (groupBy === 'month') { if (groupBy === 'month') {
@ -227,6 +222,15 @@ export class PortfolioController {
})); }));
} }
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
}
return { investments }; return { investments };
} }
@ -240,7 +244,8 @@ export class PortfolioController {
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange, dateRange,
impersonationId impersonationId,
userId: this.request.user.id
}); });
if ( if (
@ -274,6 +279,17 @@ export class PortfolioController {
); );
} }
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
}
);
}
return performanceInformation; return performanceInformation;
} }
@ -331,7 +347,7 @@ export class PortfolioController {
dateRange: 'max', dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: access.userId userId: user.id
}); });
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
@ -413,16 +429,19 @@ export class PortfolioController {
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId: string @Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId);
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic' this.request.user.subscription.type === 'Basic'
) { ) {
throw new HttpException( for (const rule in report.rules) {
getReasonPhrase(StatusCodes.FORBIDDEN), if (report.rules[rule]) {
StatusCodes.FORBIDDEN report.rules[rule] = [];
); }
}
} }
return await this.portfolioService.getReport(impersonationId); return report;
} }
} }

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 { OrderService } from '@ghostfolio/api/app/order/order.service';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; 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 { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -32,11 +31,13 @@ import {
HistoricalDataItem, HistoricalDataItem,
PortfolioDetails, PortfolioDetails,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition, TimelinePosition,
UserSettings UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
@ -67,11 +68,9 @@ import {
isAfter, isAfter,
isBefore, isBefore,
max, max,
parse,
parseISO, parseISO,
set, set,
setDayOfYear, setDayOfYear,
startOfDay,
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
@ -130,9 +129,9 @@ export class PortfolioService {
}), }),
this.getDetails({ this.getDetails({
filters, filters,
userId,
withExcludedAccounts, withExcludedAccounts,
impersonationId: userId impersonationId: userId,
userId: this.request.user.id
}) })
]); ]);
@ -304,12 +303,16 @@ export class PortfolioService {
public async getChart({ public async getChart({
dateRange = 'max', dateRange = 'max',
impersonationId impersonationId,
userCurrency,
userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
impersonationId: string; impersonationId: string;
userCurrency: string;
userId: string;
}): Promise<HistoricalDataContainer> { }): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(impersonationId, this.request.user.id); userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -317,7 +320,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -355,28 +358,24 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
impersonationId, impersonationId,
userId,
dateRange = 'max', dateRange = 'max',
filters, filters,
userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
impersonationId: string; impersonationId: string;
userId: string;
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
// TODO
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (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 } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -540,7 +539,11 @@ export class PortfolioService {
withExcludedAccounts withExcludedAccounts
}); });
const summary = await this.getSummary({ impersonationId }); const summary = await this.getSummary({
impersonationId,
userCurrency,
userId
});
return { return {
accounts, accounts,
@ -560,8 +563,9 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); 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 = ( const orders = (
await this.orderService.getOrders({ await this.orderService.getOrders({
@ -883,12 +887,16 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
impersonationId impersonationId,
userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
impersonationId: string; impersonationId: string;
userId: string;
}): Promise<PortfolioPerformanceResponse> { }): 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 } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -896,7 +904,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -947,7 +955,9 @@ export class PortfolioService {
const historicalDataContainer = await this.getChart({ const historicalDataContainer = await this.getChart({
dateRange, dateRange,
impersonationId impersonationId,
userCurrency,
userId
}); });
const itemOfToday = historicalDataContainer.items.find((item) => { const itemOfToday = historicalDataContainer.items.find((item) => {
@ -995,8 +1005,9 @@ export class PortfolioService {
} }
public async getReport(impersonationId: string): Promise<PortfolioReport> { 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 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 } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -1010,7 +1021,7 @@ export class PortfolioService {
} }
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -1030,7 +1041,7 @@ export class PortfolioService {
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userId, userId,
userCurrency: currency userCurrency
}); });
return { return {
rules: { rules: {
@ -1077,7 +1088,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees(orders).toNumber() this.getFees({ orders, userCurrency }).toNumber()
) )
], ],
<UserSettings>this.request.user.Settings.settings <UserSettings>this.request.user.Settings.settings
@ -1099,7 +1110,12 @@ export class PortfolioService {
value: Big; value: Big;
userCurrency: string; userCurrency: string;
}) { }) {
const cashPositions: PortfolioDetails['holdings'] = {}; const cashPositions: PortfolioDetails['holdings'] = {
[userCurrency]: this.getInitialCashPosition({
balance: 0,
currency: userCurrency
})
};
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency( const convertedBalance = this.exchangeRateDataService.toCurrency(
@ -1116,28 +1132,10 @@ export class PortfolioService {
cashPositions[account.currency].investment += convertedBalance; cashPositions[account.currency].investment += convertedBalance;
cashPositions[account.currency].value += convertedBalance; cashPositions[account.currency].value += convertedBalance;
} else { } else {
cashPositions[account.currency] = { cashPositions[account.currency] = this.getInitialCashPosition({
allocationCurrent: 0, balance: convertedBalance,
allocationInvestment: 0, currency: account.currency
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
};
} }
} }
@ -1165,22 +1163,26 @@ export class PortfolioService {
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency // Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big( cashPositions[symbol].allocationCurrent = value.gt(0)
cashPositions[symbol].value ? new Big(cashPositions[symbol].value).div(value).toNumber()
) : 0;
.div(value) cashPositions[symbol].allocationInvestment = investment.gt(0)
.toNumber(); ? new Big(cashPositions[symbol].investment).div(investment).toNumber()
cashPositions[symbol].allocationInvestment = new Big( : 0;
cashPositions[symbol].investment
)
.div(investment)
.toNumber();
} }
return cashPositions; 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 return orders
.filter((order) => { .filter((order) => {
// Filter out all orders before given date and type dividend // Filter out all orders before given date and type dividend
@ -1193,7 +1195,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency userCurrency
); );
}) })
.reduce( .reduce(
@ -1202,7 +1204,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 return orders
.filter((order) => { .filter((order) => {
// Filter out all orders before given date // Filter out all orders before given date
@ -1212,7 +1222,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency userCurrency
); );
}) })
.reduce( .reduce(
@ -1221,6 +1231,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)) { private getItems(orders: OrderWithAccount[], date = new Date(0)) {
return orders return orders
.filter((order) => { .filter((order) => {
@ -1262,16 +1303,20 @@ export class PortfolioService {
} }
private async getSummary({ private async getSummary({
impersonationId impersonationId,
userCurrency,
userId
}: { }: {
impersonationId: string; impersonationId: string;
userCurrency: string;
userId: string;
}): Promise<PortfolioSummary> { }): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.settings.baseCurrency; userId = await this.getUserId(impersonationId, userId);
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance({ const performanceInformation = await this.getPerformance({
impersonationId impersonationId,
userId
}); });
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({ const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
@ -1293,11 +1338,11 @@ export class PortfolioService {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
const dividend = this.getDividend(orders).toNumber(); const dividend = this.getDividend({ orders, userCurrency }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (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 firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber(); const items = this.getItems(orders).toNumber();
@ -1565,4 +1610,12 @@ export class PortfolioService {
}) })
.reduce((previous, current) => previous + current, 0); .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

@ -91,10 +91,19 @@ export class SymbolController {
); );
} }
return this.symbolService.getForDate({ const result = await this.symbolService.getForDate({
dataSource, dataSource,
date, date,
symbol symbol
}); });
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
} }
} }

View File

@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
@ -32,7 +31,7 @@ export class SymbolService {
]); ]);
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice >= 0) {
let historicalData: HistoricalDataItem[] = []; let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData > 0) { if (includeHistoricalData > 0) {
@ -65,13 +64,9 @@ export class SymbolService {
public async getForDate({ public async getForDate({
dataSource, dataSource,
date, date = new Date(),
symbol symbol
}: { }: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [{ dataSource, symbol }],
date, date,

View File

@ -19,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
if (accounts.length === 1) { if (accounts.length === 1) {
return { return {
evaluation: `All your investment is managed by a single account`, evaluation: `Your net worth is managed by a single account`,
value: false value: false
}; };
} }
return { return {
evaluation: `Your investment is managed by ${accounts.length} accounts`, evaluation: `Your net worth is managed by ${accounts.length} accounts`,
value: true value: true
}; };
} }

View File

@ -12,11 +12,14 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, { this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), 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 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO] default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
}), }),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),

View File

@ -114,10 +114,14 @@ export class DataProviderService {
} }
} }
try {
const allData = await Promise.all(promises); const allData = await Promise.all(promises);
for (const { data, symbol } of allData) { for (const { data, symbol } of allData) {
result[symbol] = data; result[symbol] = data;
} }
} catch (error) {
Logger.error(error, 'DataProviderService');
}
return result; return result;
} }
@ -209,7 +213,9 @@ export class DataProviderService {
} }
Logger.debug( Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${( `Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`
@ -223,7 +229,7 @@ export class DataProviderService {
Logger.debug('------------------------------------------------'); Logger.debug('------------------------------------------------');
Logger.debug( Logger.debug(
`Fetched ${items.length} quotes in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`

View File

@ -4,13 +4,18 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
public constructor() {} public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return false; return false;
@ -42,10 +47,77 @@ export class ManualService implements DataProviderInterface {
public async getQuotes( public async getQuotes(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return response;
}
try {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: aSymbols.length,
where: {
symbol: {
in: aSymbols
}
}
});
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice:
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice ?? 0,
marketState: 'delayed'
};
}
return response;
} catch (error) {
Logger.error(error, 'ManualService');
}
return {}; return {};
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
} }
} }

View File

@ -206,6 +206,9 @@ export class YahooFinanceService implements DataProviderInterface {
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber(); marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) {
// Convert ZAR to ZAc (cents)
marketPrice = new Big(marketPrice).mul(100).toNumber();
} }
response[symbol][format(historicalItem.date, DATE_FORMAT)] = { response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
@ -287,6 +290,18 @@ export class YahooFinanceService implements DataProviderInterface {
.mul(100) .mul(100)
.toNumber() .toNumber()
}; };
} else if (
symbol === `${this.baseCurrency}ZAR` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`)
) {
// Convert ZAR to ZAc (cents)
response[`${this.baseCurrency}ZAc`] = {
...response[symbol],
currency: 'ZAc',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
};
} }
} }

View File

@ -4,16 +4,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MarketDataModule } from './market-data.module';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
@Module({ @Module({
exports: [ExchangeRateDataService],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule PropertyModule
], ],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService]
exports: [ExchangeRateDataService]
}) })
export class ExchangeRateDataModule {} export class ExchangeRateDataModule {}

View File

@ -1,12 +1,13 @@
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format, isToday } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service'; import { PropertyService } from './property/property.service';
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -152,6 +154,53 @@ export class ExchangeRateDataService {
return aValue; return aValue;
} }
public async toCurrencyAtDate(
aValue: number,
aFromCurrency: string,
aToCurrency: string,
aDate: Date
) {
if (aValue === 0) {
return 0;
}
if (isToday(aDate)) {
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
}
let factor = 1;
if (aFromCurrency !== aToCurrency) {
const dataSource = this.dataProviderService.getPrimaryDataSource();
const symbol = `${aFromCurrency}${aToCurrency}`;
const marketData = await this.marketDataService.get({
dataSource,
symbol,
date: aDate
});
if (marketData?.marketPrice) {
factor = marketData?.marketPrice;
} else {
// TODO: Get from data provider service or calculate indirectly via base currency
// and market data
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
}
}
if (isNumber(factor) && !isNaN(factor)) {
return factor * aValue;
}
// Fallback with error, if currencies are not available
Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
'ExchangeRateDataService'
);
return aValue;
}
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
let currencies: string[] = []; let currencies: string[] = [];

View File

@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
import { IDataGatheringItem } from './interfaces/interfaces';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
@ -20,14 +22,13 @@ export class MarketDataService {
} }
public async get({ public async get({
date, dataSource,
date = new Date(),
symbol symbol
}: { }: IDataGatheringItem): Promise<MarketData> {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prismaService.marketData.findFirst({ return await this.prismaService.marketData.findFirst({
where: { where: {
dataSource,
symbol, symbol,
date: resetHours(date) date: resetHours(date)
} }

View File

@ -8,25 +8,14 @@ import {
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client';
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({ public async delete({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
@ -43,7 +32,12 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true }, include: {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
},
where: { where: {
AND: [ AND: [
{ {
@ -69,7 +63,12 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true }, include: {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
},
where: { where: {
id: { id: {
in: symbolProfileIds.map((symbolProfileId) => { in: symbolProfileIds.map((symbolProfileId) => {
@ -89,7 +88,12 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true }, include: {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
},
where: { where: {
symbol: { symbol: {
in: symbols in: symbols
@ -99,14 +103,28 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
} }
public updateSymbolProfile({
comment,
dataSource,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({
data: { comment, symbolMapping },
where: { dataSource_symbol: { dataSource, symbol } }
});
}
private getSymbols( private getSymbols(
symbolProfiles: (SymbolProfile & { symbolProfiles: (SymbolProfile & {
_count: { Order: number };
SymbolProfileOverrides: SymbolProfileOverrides; SymbolProfileOverrides: SymbolProfileOverrides;
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const item = { const item = {
...symbolProfile, ...symbolProfile,
activitiesCount: 0,
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfile?.countries as unknown as Prisma.JsonArray
), ),
@ -115,6 +133,9 @@ export class SymbolProfileService {
symbolMapping: this.getSymbolMapping(symbolProfile) symbolMapping: this.getSymbolMapping(symbolProfile)
}; };
item.activitiesCount = symbolProfile._count.Order;
delete item._count;
if (item.SymbolProfileOverrides) { if (item.SymbolProfileOverrides) {
item.assetClass = item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass; item.SymbolProfileOverrides.assetClass ?? item.assetClass;

View File

@ -0,0 +1,23 @@
{
"name": "client-e2e",
"$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"]
}

View File

@ -0,0 +1,30 @@
{
"$schema": "../../node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/assets/site.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

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

@ -0,0 +1,209 @@
{
"name": "client",
"$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": "site.webmanifest",
"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,
"serviceWorker": true,
"ngswConfigPath": "apps/client/ngsw-config.json"
},
"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

@ -102,6 +102,13 @@ const routes: Routes = [
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module' './pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
).then((m) => m.Hacktoberfest2022PageModule) ).then((m) => m.Hacktoberfest2022PageModule)
}, },
{
path: 'blog/2022/11/black-friday-2022',
loadChildren: () =>
import(
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
).then((m) => m.BlackFriday2022PageModule)
},
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
@ -145,48 +152,6 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (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', path: 'pricing',
loadChildren: () => loadChildren: () =>

View File

@ -10,8 +10,10 @@ import {
MatNativeDateModule MatNativeDateModule
} from '@angular/material/core'; } from '@angular/material/core';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { MaterialCssVarsModule } from 'angular-material-css-vars'; import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -49,8 +51,13 @@ export function NgxStripeFactory(): string {
}), }),
MatNativeDateModule, MatNativeDateModule,
MatSnackBarModule, MatSnackBarModule,
MatTooltipModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
NgxStripeModule.forRoot(environment.stripePublicKey) NgxStripeModule.forRoot(environment.stripePublicKey),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerImmediately'
})
], ],
providers: [ providers: [
authInterceptorProviders, authInterceptorProviders,

View File

@ -1,6 +1,32 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
<ng-container matColumnDef="status">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-center">
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline"></ion-icon>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th> <th
*matHeaderCellDef
class="px-1"
i18n
mat-header-cell
mat-sort-header="name"
>
Name
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<gf-symbol-icon <gf-symbol-icon
*ngIf="element.Platform?.url" *ngIf="element.Platform?.url"
@ -15,11 +41,16 @@
>(Default)</span >(Default)</span
> >
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container> </ng-container>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Currency</ng-container> <ng-container i18n>Currency</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
@ -31,7 +62,12 @@
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
mat-sort-header="Platform.name"
>
<ng-container i18n>Platform</ng-container> <ng-container i18n>Platform</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
@ -53,7 +89,12 @@
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell> <th
*matHeaderCellDef
class="px-1 text-right"
mat-header-cell
mat-sort-header="transactionCount"
>
<span class="d-block d-sm-none">#</span> <span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Activities</span> <span class="d-none d-sm-block" i18n>Activities</span>
</th> </th>
@ -70,8 +111,9 @@
<ng-container matColumnDef="balance"> <ng-container matColumnDef="balance">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header
> >
<ng-container i18n>Cash Balance</ng-container> <ng-container i18n>Cash Balance</ng-container>
</th> </th>
@ -104,8 +146,9 @@
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header
> >
<ng-container i18n>Value</ng-container> <ng-container i18n>Value</ng-container>
</th> </th>
@ -140,6 +183,7 @@
*matHeaderCellDef *matHeaderCellDef
class="d-lg-none d-xl-none px-1 text-right" class="d-lg-none d-xl-none px-1 text-right"
mat-header-cell mat-header-cell
mat-sort-header
> >
<ng-container i18n>Value</ng-container> <ng-container i18n>Value</ng-container>
</th> </th>

View File

@ -6,11 +6,14 @@ import {
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output Output,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { get } from 'lodash';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@Component({ @Component({
@ -32,6 +35,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = public dataSource: MatTableDataSource<AccountModel> =
new MatTableDataSource(); new MatTableDataSource();
public displayedColumns = []; public displayedColumns = [];
@ -46,6 +51,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = [
'status',
'account', 'account',
'platform', 'platform',
'transactions', 'transactions',
@ -63,6 +69,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
if (this.accounts) { if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts); this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.isLoading = false; this.isLoading = false;
} }

View File

@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
@ -21,6 +22,7 @@ import { AccountsTableComponent } from './accounts-table.component';
MatButtonModule, MatButtonModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
RouterModule RouterModule

View File

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

View File

@ -16,6 +16,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource } from '@prisma/client'; import { AssetSubClass, DataSource } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -44,10 +45,10 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
AssetSubClass.PRECIOUS_METAL, AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY, AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK AssetSubClass.STOCK
].map((id) => { ].map((assetSubClass) => {
return { return {
id, id: assetSubClass,
label: id, label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS' type: 'ASSET_SUB_CLASS'
}; };
}); });
@ -63,10 +64,11 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
'assetClass', 'assetClass',
'assetSubClass', 'assetSubClass',
'date', 'date',
'activityCount', 'activitiesCount',
'marketDataItemCount', 'marketDataItemCount',
'countriesCount',
'sectorsCount', 'sectorsCount',
'countriesCount',
'comment',
'actions' 'actions'
]; ];
public filters$ = new Subject<Filter[]>(); public filters$ = new Subject<Filter[]>();
@ -150,6 +152,35 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .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) { public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })

View File

@ -13,10 +13,10 @@
<div class="col"> <div class="col">
<table <table
class="gf-table w-100" class="gf-table w-100"
mat-table
matSort matSort
matSortActive="symbol" matSortActive="symbol"
matSortDirection="asc" matSortDirection="asc"
mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="symbol"> <ng-container matColumnDef="symbol">
@ -64,12 +64,12 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="activityCount"> <ng-container matColumnDef="activitiesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Activity Count</ng-container> <ng-container i18n>Activities Count</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }} {{ element.activitiesCount }}
</td> </td>
</ng-container> </ng-container>
@ -82,15 +82,6 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="sectorsCount"> <ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Sectors Count</ng-container> <ng-container i18n>Sectors Count</ng-container>
@ -100,18 +91,63 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="comment">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header
></th>
<td *matCellDef="let element" class="px-1" mat-cell>
<ion-icon
*ngIf="element.comment"
class="d-block"
name="document-text-outline"
></ion-icon>
</td>
</ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="accountMenu" [matMenuTriggerFor]="assetProfilesActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </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 <button
mat-menu-item mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})" (click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
@ -126,7 +162,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activityCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
<ng-container i18n>Delete</ng-container> <ng-container i18n>Delete</ng-container>

View File

@ -6,9 +6,14 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import {
EnhancedSymbolProfile,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { MarketData } from '@prisma/client'; import { MarketData } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -23,51 +28,126 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'] styleUrls: ['./asset-profile-dialog.component.scss']
}) })
export class AssetProfileDialog implements OnDestroy, OnInit { export class AssetProfileDialog implements OnDestroy, OnInit {
public assetProfile: EnhancedSymbolProfile;
public assetProfileForm = this.formBuilder.group({
comment: '',
symbolMapping: ''
});
public countries: {
[code: string]: { name: string; value: number };
};
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public sectors: {
[name: string]: { name: string; value: number };
};
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams private formBuilder: FormBuilder
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
this.initialize(); this.initialize();
} }
public initialize() {
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile;
this.countries = {};
this.marketDataDetails = marketData;
this.sectors = {};
if (assetProfile?.countries?.length > 0) {
for (const country of assetProfile.countries) {
this.countries[country.code] = {
name: country.name,
value: country.weight
};
}
}
if (assetProfile?.sectors?.length > 0) {
for (const sector of assetProfile.sectors) {
this.sectors[sector.name] = {
name: sector.name,
value: sector.weight
};
}
}
this.assetProfileForm.setValue({
comment: this.assetProfile?.comment,
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping)
});
this.assetProfileForm.markAsPristine();
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void { public onClose(): void {
this.dialogRef.close(); this.dialogRef.close();
} }
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();
} }
} }
public onSubmit() {
let symbolMapping = {};
try {
symbolMapping = JSON.parse(
this.assetProfileForm.controls['symbolMapping'].value
);
} catch {}
const assetProfileData: UpdateAssetProfileDto = {
symbolMapping,
comment: this.assetProfileForm.controls['comment'].value ?? null
};
this.adminService
.patchAssetProfile({
...assetProfileData,
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.subscribe(() => {
this.initialize();
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
});
}
private initialize() {
this.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
});
}
} }

View File

@ -1,13 +1,48 @@
<gf-dialog-header <form
mat-dialog-title class="d-flex flex-column h-100"
position="center" [formGroup]="assetProfileForm"
[deviceType]="data.deviceType" (keyup.enter)="assetProfileForm.valid && onSubmit()"
[title]="data.symbol" (ngSubmit)="onSubmit()"
(closeButtonClicked)="onClose()" >
></gf-dialog-header> <div class="d-flex mb-3">
<h1 class="flex-grow-1 m-0" mat-dialog-title>
{{ assetProfile?.name ?? data.symbol }}
</h1>
<button
class="mx-1 no-min-width px-2"
mat-button
type="button"
[matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button mat-menu-item type="button" (click)="initialize()">
<ng-container i18n>Refresh</ng-container>
</button>
<button
mat-menu-item
type="button"
[disabled]="assetProfileForm.dirty"
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Gather Data</ng-container>
</button>
<button
mat-menu-item
type="button"
[disabled]="assetProfileForm.dirty"
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
</mat-menu>
</div>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail <gf-admin-market-data-detail
class="mb-3"
[dataSource]="data.dataSource" [dataSource]="data.dataSource"
[dateOfFirstActivity]="data.dateOfFirstActivity" [dateOfFirstActivity]="data.dateOfFirstActivity"
[locale]="data.locale" [locale]="data.locale"
@ -15,10 +50,127 @@
[symbol]="data.symbol" [symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail> ></gf-admin-market-data-detail>
<div class="row">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isDate]="data.dateOfFirstActivity ? true : false"
[locale]="data.locale"
[value]="data.dateOfFirstActivity ?? '-'"
>First Buy Date</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.activitiesCount ?? 0"
>Transactions</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[hidden]="!assetProfile?.assetClass"
[value]="assetProfile?.assetClass"
>Asset Class</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[hidden]="!assetProfile?.assetSubClass"
[value]="assetProfile?.assetSubClass"
>Asset Sub Class</gf-value
>
</div>
<ng-container
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0"
>
<ng-container
*ngIf="assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1; else charts"
>
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.sectors[0].name"
>Sector</gf-value
>
</div>
<div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.countries[0].name"
>Country</gf-value
>
</div>
</ng-container>
<ng-template #charts>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>
</div>
</ng-template>
</ng-container>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol Mapping</mat-label>
<textarea
cdkTextareaAutosize
formControlName="symbolMapping"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="comment"
matInput
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
</div> </div>
<gf-dialog-footer <div class="d-flex justify-content-end" mat-dialog-actions>
mat-dialog-actions <button i18n mat-button type="button" (click)="onClose()">Cancel</button>
[deviceType]="data.deviceType" <button
(closeButtonClicked)="onClose()" color="primary"
></gf-dialog-footer> mat-flat-button
type="submit"
[disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -1,10 +1,14 @@
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfValueModule } from '@ghostfolio/ui/value';
import { AssetProfileDialog } from './asset-profile-dialog.component'; import { AssetProfileDialog } from './asset-profile-dialog.component';
@ -12,11 +16,16 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
declarations: [AssetProfileDialog], declarations: [AssetProfileDialog],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
GfAdminMarketDataDetailModule, GfAdminMarketDataDetailModule,
GfDialogFooterModule, GfPortfolioProportionChartModule,
GfDialogHeaderModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatDialogModule MatDialogModule,
MatInputModule,
MatMenuModule,
ReactiveFormsModule,
TextFieldModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.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>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -162,35 +160,6 @@ 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) { public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE, key: PROPERTY_IS_READ_ONLY_MODE,

View File

@ -27,53 +27,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex my-3">
<div class="w-50" i18n>Data Management</div>
<div class="w-50">
<div class="overflow-hidden">
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onGather7Days()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Recent Data</span>
</button>
</div>
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onGatherMax()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
<div>
<button
class="mb-2 mr-2"
color="accent"
mat-flat-button
(click)="onGatherProfileData()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Profile Data</span>
</button>
</div>
</div>
</div>
</div>
<div class="align-items-start d-flex my-3"> <div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div> <div class="w-50" i18n>Exchange Rates</div>
<div class="w-50"> <div class="w-50">

View File

@ -37,6 +37,7 @@
<gf-premium-indicator <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false"
></gf-premium-indicator> ></gf-premium-indicator>
</div> </div>
</td> </td>

View File

@ -18,6 +18,7 @@
<mat-label i18n>Compare with...</mat-label> <mat-label i18n>Compare with...</mat-label>
<mat-select <mat-select
name="benchmark" name="benchmark"
[disabled]="user?.subscription?.type === 'Basic'"
[value]="benchmark" [value]="benchmark"
(selectionChange)="onChangeBenchmark($event.value)" (selectionChange)="onChangeBenchmark($event.value)"
> >

View File

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

View File

@ -38,6 +38,7 @@ import {
} from 'chart.js'; } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns'; import { addDays, format, isAfter, parseISO, subDays } from 'date-fns';
import { last } from 'lodash';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -53,6 +54,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() groupBy: GroupBy; @Input() groupBy: GroupBy;
@Input() historicalDataItems: LineChartItem[] = []; @Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() isLoading = false;
@Input() locale: string; @Input() locale: string;
@Input() range: DateRange = 'max'; @Input() range: DateRange = 'max';
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ -60,9 +62,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>; public chart: Chart<any>;
public isLoading = true; private investments: InvestmentItem[];
private values: LineChartItem[];
private data: InvestmentItem[];
public constructor() { public constructor() {
Chart.register( Chart.register(
@ -92,34 +93,49 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
private initialize() { private initialize() {
this.isLoading = true;
// Create a clone // Create a clone
this.data = this.benchmarkDataItems.map((item) => Object.assign({}, item)); this.investments = this.benchmarkDataItems.map((item) =>
Object.assign({}, item)
);
this.values = this.historicalDataItems.map((item) =>
Object.assign({}, item)
);
if (!this.groupBy && this.investments?.length > 0) {
let date: string;
if (!this.groupBy && this.data?.length > 0) {
if (this.range === 'max') { if (this.range === 'max') {
// Extend chart by 5% of days in market (before) // Extend chart by 5% of days in market (before)
const firstItem = this.data[0]; date = format(
this.data.unshift({ subDays(
...firstItem, parseISO(this.investments[0].date),
date: format( this.daysInMarket * 0.05 || 90
subDays(parseISO(firstItem.date), this.daysInMarket * 0.05 || 90),
DATE_FORMAT
), ),
DATE_FORMAT
);
this.investments.unshift({
date,
investment: 0 investment: 0
}); });
this.values.unshift({
date,
value: 0
});
} }
// Extend chart by 5% of days in market (after) // Extend chart by 5% of days in market (after)
const lastItem = this.data[this.data.length - 1]; date = format(
this.data.push({ addDays(
...lastItem, parseDate(last(this.investments).date),
date: format( this.daysInMarket * 0.05 || 90
addDays(parseDate(lastItem.date), this.daysInMarket * 0.05 || 90), ),
DATE_FORMAT DATE_FORMAT
) );
this.investments.push({
date,
investment: last(this.investments).investment
}); });
this.values.push({ date, value: last(this.values).value });
} }
const data = { const data = {
@ -131,7 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 1, borderWidth: this.groupBy ? 0 : 1,
data: this.data.map(({ date, investment }) => { data: this.investments.map(({ date, investment }) => {
return { return {
x: parseDate(date), x: parseDate(date),
y: this.isInPercent ? investment * 100 : investment y: this.isInPercent ? investment * 100 : investment
@ -151,7 +167,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
{ {
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2, borderWidth: 2,
data: this.historicalDataItems.map(({ date, value }) => { data: this.values.map(({ date, value }) => {
return { return {
x: parseDate(date), x: parseDate(date),
y: this.isInPercent ? value * 100 : value y: this.isInPercent ? value * 100 : value
@ -159,7 +175,15 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}), }),
fill: false, fill: false,
label: $localize`Total Amount`, label: $localize`Total Amount`,
pointRadius: 0 pointRadius: 0,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
}
} }
] ]
}; };
@ -273,8 +297,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}); });
} }
} }
this.isLoading = false;
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration() {

View File

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

View File

@ -14,6 +14,7 @@ import {
LineChartItem LineChartItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -29,6 +30,8 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss'] styleUrls: ['./position-detail-dialog.component.scss']
}) })
export class PositionDetailDialog implements OnDestroy, OnInit { export class PositionDetailDialog implements OnDestroy, OnInit {
public assetClass: string;
public assetSubClass: string;
public averagePrice: number; public averagePrice: number;
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
public countries: { public countries: {
@ -126,6 +129,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.value = value; this.value = value;
if (SymbolProfile?.assetClass) {
this.assetClass = translate(SymbolProfile?.assetClass);
}
if (SymbolProfile?.assetSubClass) {
this.assetSubClass = translate(SymbolProfile?.assetSubClass);
}
if (SymbolProfile?.countries?.length > 0) { if (SymbolProfile?.countries?.length > 0) {
for (const country of SymbolProfile.countries) { for (const country of SymbolProfile.countries) {
this.countries[country.code] = { this.countries[country.code] = {

View File

@ -139,11 +139,7 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
i18n
size="medium"
[hidden]="!SymbolProfile?.assetClass"
[value]="SymbolProfile?.assetClass"
>Asset Class</gf-value >Asset Class</gf-value
> >
</div> </div>
@ -151,8 +147,8 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[hidden]="!SymbolProfile?.assetSubClass" [hidden]="!assetSubClass"
[value]="SymbolProfile?.assetSubClass" [value]="assetSubClass"
>Asset Sub Class</gf-value >Asset Sub Class</gf-value
> >
</div> </div>
@ -213,7 +209,7 @@
</ng-container> </ng-container>
</div> </div>
<div *ngIf="orders?.length > 0" class="row"> <div class="row" [ngClass]="{ 'd-none': !orders?.length }">
<div class="col mb-3"> <div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div> <div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table <gf-activities-table
@ -221,7 +217,7 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false" [hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"

View File

@ -1,9 +1,9 @@
<table <table
class="gf-table w-100" class="gf-table w-100"
mat-table
matSort matSort
matSortActive="allocationCurrent" matSortActive="allocationCurrent"
matSortDirection="desc" matSortDirection="desc"
mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="icon"> <ng-container matColumnDef="icon">
@ -51,7 +51,7 @@
> >
<ng-container i18n>Value</ng-container> <ng-container i18n>Value</ng-container>
</th> </th>
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element"> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
@ -87,6 +87,7 @@
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right" class="d-none d-lg-table-cell px-1 text-right"
mat-header-cell mat-header-cell
mat-sort-header="netPerformancePercent"
> >
<ng-container i18n>Performance</ng-container> <ng-container i18n>Performance</ng-container>
</th> </th>

View File

@ -10,7 +10,7 @@
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>
</mat-card> </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"> <ng-container *ngIf="rules !== null && rules !== undefined">
<gf-rule *ngFor="let rule of rules" [rule]="rule"></gf-rule> <gf-rule *ngFor="let rule of rules" [rule]="rule"></gf-rule>
</ng-container> </ng-container>

View File

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

View File

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

View File

@ -77,12 +77,16 @@ export class AuthGuard implements CanActivate {
if (userLanguage && document.documentElement.lang !== userLanguage) { if (userLanguage && document.documentElement.lang !== userLanguage) {
this.dataService this.dataService
.putUserSetting({ language: userLanguage }) .putUserSetting({ language: document.documentElement.lang })
.subscribe(() => { .subscribe(() => {
this.userService.remove(); this.userService.remove();
setTimeout(() => {
window.location.reload();
}, 300);
}); });
resolve(false); resolve(true);
return; return;
} else if ( } else if (
state.url.startsWith('/home') && state.url.startsWith('/home') &&

View File

@ -96,6 +96,20 @@
title="Ghostfolio is an independent & bootstrapped business" title="Ghostfolio is an independent & bootstrapped business"
></div> ></div>
</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> </div>
</div> </div>
@ -177,7 +191,7 @@
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
mat-stroked-button mat-flat-button
[routerLink]="['/faq']" [routerLink]="['/faq']"
>FAQ</a >FAQ</a
> >
@ -189,7 +203,7 @@
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
mat-stroked-button mat-flat-button
[routerLink]="['/about', 'changelog']" [routerLink]="['/about', 'changelog']"
>Changelog & License</a >Changelog & License</a
> >
@ -198,7 +212,7 @@
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary" color="primary"
mat-stroked-button mat-flat-button
[routerLink]="['/about', 'privacy-policy']" [routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a >Privacy Policy</a
> >

View File

@ -303,6 +303,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
} }
} }
public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -116,7 +116,6 @@
<div class="align-items-center d-flex mb-2"> <div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
<div i18n>Language</div> <div i18n>Language</div>
<div class="hint-text text-muted" i18n>Beta</div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-form-field <mat-form-field
@ -132,9 +131,18 @@
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option> <mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option> <mat-option value="en">English</mat-option>
<mat-option value="es">Español</mat-option> <mat-option value="es"
<mat-option value="it">Italiano</mat-option> >Español (<ng-container i18n>Community</ng-container
<mat-option value="nl">Nederlands</mat-option> >)</mat-option
>
<mat-option value="it"
>Italiano (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="nl"
>Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option
>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -167,29 +175,6 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<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>
<div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden">
<mat-form-field
appearance="outline"
class="compact-with-outline w-100 without-hint"
>
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSetting('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
<div class="d-flex"> <div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50"> <div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>Appearance</ng-container> <ng-container i18n>Appearance</ng-container>
@ -216,6 +201,19 @@
</div> </div>
</form> </form>
</div> </div>
<div class="d-flex mt-4 py-1">
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>Zen Mode</ng-container>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
[checked]="user.settings.viewMode === 'ZEN'"
[disabled]="!hasPermissionToUpdateViewMode"
(change)="onViewModeChange($event)"
></mat-slide-toggle>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>Sign in with fingerprint</div> <div class="pr-1 w-50" i18n>Sign in with fingerprint</div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">

View File

@ -2,14 +2,14 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ReportPageComponent } from './report-page.component'; import { BlackFriday2022PageComponent } from './black-friday-2022-page.component';
const routes: Routes = [ const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: ReportPageComponent, component: BlackFriday2022PageComponent,
path: '', path: '',
title: 'X-ray' title: 'Black Friday 2022'
} }
]; ];
@ -17,4 +17,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule] exports: [RouterModule]
}) })
export class ReportPageRoutingModule {} export class BlackFriday2022RoutingModule {}

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-black-friday-2022-page',
styleUrls: ['./black-friday-2022-page.scss'],
templateUrl: './black-friday-2022-page.html'
})
export class BlackFriday2022PageComponent {
public constructor() {}
}

View File

@ -0,0 +1,138 @@
<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">Black Friday 2022</h1>
<div class="mb-3 text-muted"><small>2022-11-13</small></div>
<img
alt="Black Friday 2022 Teaser"
class="rounded w-100"
src="../assets/images/blog/black-friday-2022.jpg"
title="Black Friday 2022"
/>
</div>
<section class="mb-4">
<p>
Get 75% off on our
<strong>Ghostfolio Premium</strong>
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator>
annual plan for ambitious investors who need the full picture of
their financial assets.
</p>
</section>
<section class="mb-4">
<p>
<a
href="https://ghostfol.io"
title="Open Source Wealth Management Software"
>Ghostfolio</a
>
is a modern web application to manage your personal finance. The
software presents the current assets (stocks, ETFs,
cryptocurrencies, commodities etc.) in real time to make solid,
data-driven investment decisions. Check out the numerous
<a [routerLink]="['/features']">features</a> to manage your wealth.
</p>
</section>
<section class="mb-4">
<p>
Snap the limited Black Friday 2022 deal before its gone. For
detailed information on plans and pricing, please visit our
<a [routerLink]="['/pricing']">pricing page</a>.
</p>
<p class="text-center">
<a color="primary" mat-flat-button [routerLink]="['/pricing']"
>Get the Deal</a
>
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">2022</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Black Friday</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cloud</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">Deal</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">Ghostfolio Premium</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hosting</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">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">Pricing</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</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">Subscription</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,21 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { BlackFriday2022RoutingModule } from './black-friday-2022-page-routing.module';
import { BlackFriday2022PageComponent } from './black-friday-2022-page.component';
@NgModule({
declarations: [BlackFriday2022PageComponent],
imports: [
BlackFriday2022RoutingModule,
CommonModule,
GfPremiumIndicatorModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class BlackFriday2022PageModule {}

View File

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

View File

@ -1,4 +1,6 @@
import { Component, OnDestroy } from '@angular/core'; import { Component, OnDestroy } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
@ -8,9 +10,18 @@ import { Subject } from 'rxjs';
templateUrl: './blog-page.html' templateUrl: './blog-page.html'
}) })
export class BlogPageComponent implements OnDestroy { export class BlogPageComponent implements OnDestroy {
public hasPermissionForSubscription: boolean;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor() {} public constructor(private dataService: DataService) {
const info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
info?.globalPermissions,
permissions.enableSubscription
);
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

View File

@ -2,6 +2,30 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Blog</h3> <h3 class="mb-3 text-center" i18n>Blog</h3>
<mat-card *ngIf="hasPermissionForSubscription" 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/11/black-friday-2022"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Black Friday 2022</div>
<div class="d-flex text-muted">2022-11-13</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 class="mb-3">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <div class="container p-0">

View File

@ -5,9 +5,9 @@
Manage your wealth like a boss Manage your wealth like a boss
</h1> </h1>
<p class="lead mb-4"> <p class="lead mb-4">
Ghostfolio is a privacy-first, open source dashboard to manage your Ghostfolio is a privacy-first, open source dashboard for your personal
personal finances. Break down your asset allocation, know your net worth finances. Break down your asset allocation, know your net worth and make
and make solid, data-driven investment decisions. solid, data-driven investment decisions.
</p> </p>
<div class="mb-4"> <div class="mb-4">
<a <a

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; 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 { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isArray } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component'; import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component'; import { ImportActivitiesDialog } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
selector: 'gf-transactions-page', selector: 'gf-activities-page',
styleUrls: ['./transactions-page.scss'], styleUrls: ['./activities-page.scss'],
templateUrl: './transactions-page.html' templateUrl: './activities-page.html'
}) })
export class TransactionsPageComponent implements OnDestroy, OnInit { export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[]; public activities: Activity[];
public defaultAccountId: string; public defaultAccountId: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateActivity: boolean;
public hasPermissionToDeleteOrder: boolean; public hasPermissionToDeleteActivity: boolean;
public hasPermissionToImportOrders: boolean; public hasPermissionToImportActivities: boolean;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public user: User; public user: User;
@ -51,24 +49,22 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dialog: MatDialog, private dialog: MatDialog,
private icsService: IcsService, private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateTransactionDialog(); this.openCreateActivityDialog();
} else if (params['editDialog']) { } else if (params['editDialog']) {
if (this.activities) { if (this.activities) {
const transaction = this.activities.find(({ id }) => { const activity = this.activities.find(({ id }) => {
return id === params['transactionId']; return id === params['activityId'];
}); });
this.openUpdateTransactionDialog(transaction); this.openUpdateActivityDialog(activity);
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
@ -96,7 +92,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.subscribe((aId) => { .subscribe((aId) => {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
this.hasPermissionToImportOrders = this.hasPermissionToImportActivities =
hasPermission(globalPermissions, permissions.enableImport) && hasPermission(globalPermissions, permissions.enableImport) &&
!this.hasImpersonationId; !this.hasImpersonationId;
}); });
@ -121,7 +117,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.subscribe(({ activities }) => { .subscribe(({ activities }) => {
this.activities = activities; this.activities = activities;
if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) { if (
this.hasPermissionToCreateActivity &&
this.activities?.length <= 0
) {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
} }
@ -129,11 +128,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}); });
} }
public onCloneTransaction(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
this.openCreateTransactionDialog(aActivity); this.openCreateActivityDialog(aActivity);
} }
public onDeleteTransaction(aId: string) { public onDeleteActivity(aId: string) {
this.dataService this.dataService
.deleteOrder(aId) .deleteOrder(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -183,98 +182,30 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} }
public onImport() { public onImport() {
const input = document.createElement('input'); const dialogRef = this.dialog.open(ImportActivitiesDialog, {
input.accept = 'application/JSON, .csv'; data: <ImportActivitiesDialogParams>{
input.type = 'file'; deviceType: this.deviceType,
user: this.user
input.onchange = (event) => { },
this.snackBar.open('⏳' + $localize`Importing data...`); width: this.deviceType === 'mobile' ? '100vw' : '50rem'
// 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(); dialogRef
} catch (error) { .afterClosed()
console.error(error); .pipe(takeUntil(this.unsubscribeSubject))
this.handleImportError({ error, activities: content.activities }); .subscribe(() => {
} this.fetchActivities();
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; public onUpdateActivity(aActivity: OrderModel) {
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
}
};
};
input.click();
}
public onUpdateTransaction(aTransaction: OrderModel) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { editDialog: true, transactionId: aTransaction.id } queryParams: { activityId: aActivity.id, editDialog: true }
}); });
} }
public openUpdateTransactionDialog(activity: Activity): void { public openUpdateActivityDialog(activity: Activity): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
activity, activity,
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
@ -312,41 +243,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private handleImportError({ private openCreateActivityDialog(aActivity?: Activity): void {
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 {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => { .subscribe((user) => {
this.updateUser(user); this.updateUser(user);
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES'; return account.accountType === 'SECURITIES';
@ -434,11 +338,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
return account.isDefault; return account.isDefault;
})?.id; })?.id;
this.hasPermissionToCreateOrder = hasPermission( this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createOrder permissions.createOrder
); );
this.hasPermissionToDeleteOrder = hasPermission( this.hasPermissionToDeleteActivity = hasPermission(
this.user.permissions, this.user.permissions,
permissions.deleteOrder permissions.deleteOrder
); );

View File

@ -6,14 +6,14 @@
[activities]="activities" [activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" [hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToImportActivities]="hasPermissionToImportOrders" [hasPermissionToImportActivities]="hasPermissionToImportActivities"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView" [showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteTransaction($event)" (activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneTransaction($event)" (activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateTransaction($event)" (activityToUpdate)="onUpdateActivity($event)"
(export)="onExport($event)" (export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)" (exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"
@ -22,15 +22,15 @@
</div> </div>
<div <div
*ngIf="!hasImpersonationId && hasPermissionToCreateOrder && !user.settings.isRestrictedView" *ngIf="!hasImpersonationId && hasPermissionToCreateActivity && !user.settings.isRestrictedView"
class="fab-container" class="fab-container"
> >
<a <a
class="align-items-center d-flex justify-content-center" class="align-items-center d-flex justify-content-center"
color="primary" color="primary"
mat-fab mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }" [queryParams]="{ createDialog: true }"
[routerLink]="[]"
> >
<ion-icon name="add-outline" size="large"></ion-icon> <ion-icon name="add-outline" size="large"></ion-icon>
</a> </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,10 +14,11 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, AssetSubClass, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -27,21 +28,25 @@ import {
takeUntil takeUntil
} from 'rxjs/operators'; } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
@Component({ @Component({
host: { class: 'h-100' }, host: { class: 'h-100' },
selector: 'gf-create-or-update-transaction-dialog', selector: 'gf-create-or-update-activity-dialog',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-transaction-dialog.scss'], styleUrls: ['./create-or-update-activity-dialog.scss'],
templateUrl: 'create-or-update-transaction-dialog.html' templateUrl: 'create-or-update-activity-dialog.html'
}) })
export class CreateOrUpdateTransactionDialog implements OnDestroy { export class CreateOrUpdateActivityDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass); public assetClasses = Object.keys(AssetClass).map((assetClass) => {
public assetSubClasses = Object.keys(AssetSubClass); return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
@ -55,10 +60,10 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateActivityDialogParams,
private dataService: DataService, private dataService: DataService,
private dateAdapter: DateAdapter<any>, private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>, public dialogRef: MatDialogRef<CreateOrUpdateActivityDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@Inject(MAT_DATE_LOCALE) private locale: string @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}
@ -81,12 +86,17 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.data.activity?.SymbolProfile?.currency, this.data.activity?.SymbolProfile?.currency,
Validators.required Validators.required
], ],
currencyOfFee: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
dataSource: [ dataSource: [
this.data.activity?.SymbolProfile?.dataSource, this.data.activity?.SymbolProfile?.dataSource,
Validators.required Validators.required
], ],
date: [this.data.activity?.date, Validators.required], date: [this.data.activity?.date, Validators.required],
fee: [this.data.activity?.fee, Validators.required], fee: [this.data.activity?.fee, Validators.required],
feeInCustomCurrency: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.SymbolProfile?.name, Validators.required], name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required], quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [ searchSymbol: [
@ -103,7 +113,36 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm.valueChanges this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(async () => {
let exchangeRate = 1;
const currency = this.activityForm.controls['currency'].value;
const currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
const date = this.activityForm.controls['date'].value;
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
try {
const { marketPrice } = await lastValueFrom(
this.dataService
.fetchExchangeRateForDate({
date,
symbol: `${currencyOfFee}-${currency}`
})
.pipe(takeUntil(this.unsubscribeSubject))
);
exchangeRate = marketPrice;
} catch {}
}
const feeInCustomCurrency =
this.activityForm.controls['feeInCustomCurrency'].value *
exchangeRate;
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
emitEvent: false
});
if ( if (
this.activityForm.controls['type'].value === 'BUY' || this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM' this.activityForm.controls['type'].value === 'ITEM'
@ -118,6 +157,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm.controls['unitPrice'].value - this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0; this.activityForm.controls['fee'].value ?? 0;
} }
this.changeDetectorRef.markForCheck();
}); });
this.filteredLookupItemsObservable = this.activityForm.controls[ this.filteredLookupItemsObservable = this.activityForm.controls[
@ -155,6 +196,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm.controls['currency'].setValue( this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency this.data.user.settings.baseCurrency
); );
this.activityForm.controls['currencyOfFee'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
@ -184,6 +228,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
); );
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
} }
this.changeDetectorRef.markForCheck();
}); });
this.activityForm.controls['type'].setValue(this.data.activity?.type); this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -308,6 +354,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
) )
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
this.activityForm.controls['currency'].setValue(currency); this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['dataSource'].setValue(dataSource); this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;

View File

@ -4,17 +4,17 @@
(keyup.enter)="activityForm.valid && onSubmit()" (keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1> <h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1> <h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-option value="BUY" i18n>BUY</mat-option> <mat-option i18n value="BUY">BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option> <mat-option i18n value="DIVIDEND">DIVIDEND</mat-option>
<mat-option value="ITEM" i18n>ITEM</mat-option> <mat-option i18n value="ITEM">ITEM</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option> <mat-option i18n value="SELL">SELL</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -127,6 +127,23 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="feeInCustomCurrency" matInput type="number" />
<div
class="ml-2"
matSuffix
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
>
<mat-select formControlName="currencyOfFee">
<mat-option *ngFor="let currency of currencies" [value]="currency">
{{ currency }}
</mat-option>
</mat-select>
</div>
</mat-form-field>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" /> <input formControlName="fee" matInput type="number" />
@ -156,8 +173,8 @@
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option <mat-option
*ngFor="let assetClass of assetClasses" *ngFor="let assetClass of assetClasses"
[value]="assetClass" [value]="assetClass.id"
>{{ assetClass }}</mat-option >{{ assetClass.label }}</mat-option
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -171,8 +188,8 @@
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option <mat-option
*ngFor="let assetSubClass of assetSubClasses" *ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass" [value]="assetSubClass.id"
>{{ assetSubClass }}</mat-option >{{ assetSubClass.label }}</mat-option
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

View File

@ -13,15 +13,15 @@ import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value'; 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({ @NgModule({
declarations: [CreateOrUpdateTransactionDialog], declarations: [CreateOrUpdateActivityDialog],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
GfSymbolModule, GfSymbolModule,
GfValueModule, GfValueModule,
FormsModule,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule, MatChipsModule,
@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] 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 { User } from '@ghostfolio/common/interfaces';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams { export interface CreateOrUpdateActivityDialogParams {
accountId: string; accountId: string;
accounts: Account[]; accounts: Account[];
activity: Activity; 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 { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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({ @NgModule({
declarations: [ImportTransactionDialog], declarations: [ImportActivitiesDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfDialogFooterModule, GfDialogFooterModule,
@ -20,4 +20,4 @@ import { ImportTransactionDialog } from './import-transaction-dialog.component';
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfImportTransactionDialogModule {} export class GfImportActivitiesDialogModule {}

View File

@ -1,6 +1,10 @@
:host { :host {
display: block; display: block;
a {
color: rgba(var(--palette-primary-500), 1);
}
.mat-expansion-panel { .mat-expansion-panel {
background: none; background: none;
box-shadow: 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'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types'; import { Market, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -174,7 +175,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
for (const assetClass of Object.keys(AssetClass)) { for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({ assetClassFilters.push({
id: assetClass, id: assetClass,
label: assetClass, label: translate(assetClass),
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}); });
} }

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatDialogModule } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
@ -24,6 +25,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
GfWorldMapChartModule, GfWorldMapChartModule,
GfValueModule, GfValueModule,
MatCardModule, MatCardModule,
MatDialogModule,
MatProgressBarModule MatProgressBarModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -35,6 +35,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentsByMonth: InvestmentItem[]; public investmentsByMonth: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean; public isLoadingBenchmarkComparator: boolean;
public isLoadingInvestmentChart: boolean;
public mode: GroupBy = 'month'; public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [ public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' } { label: $localize`Monthly`, value: 'month' }
@ -125,6 +126,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private update() { private update() {
this.isLoadingBenchmarkComparator = true; this.isLoadingBenchmarkComparator = true;
this.isLoadingInvestmentChart = true;
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
@ -156,6 +158,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
} }
this.isLoadingInvestmentChart = false;
this.updateBenchmarkDataItems(); this.updateBenchmarkDataItems();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -125,6 +125,7 @@
[daysInMarket]="daysInMarket" [daysInMarket]="daysInMarket"
[historicalDataItems]="performanceDataItems" [historicalDataItems]="performanceDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingBenchmarkComparator"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange" [range]="user?.settings?.dateRange"
></gf-investment-chart> ></gf-investment-chart>

View File

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

View File

@ -3,7 +3,13 @@
<div class="col-lg"> <div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3> <h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div> <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 <gf-fire-calculator
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
@ -18,7 +24,13 @@
</div> </div>
</div> </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"> <div *ngIf="isLoading">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
@ -67,3 +79,61 @@
</div> </div>
</div> </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 { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; 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 { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -13,6 +15,8 @@ import { FirePageComponent } from './fire-page.component';
CommonModule, CommonModule,
FirePageRoutingModule, FirePageRoutingModule,
GfFireCalculatorModule, GfFireCalculatorModule,
GfPremiumIndicatorModule,
GfRulesModule,
GfValueModule, GfValueModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

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

View File

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

View File

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

View File

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

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