Compare commits
71 Commits
Author | SHA1 | Date | |
---|---|---|---|
f5df970685 | |||
edfdc0c346 | |||
fcfe7b1787 | |||
170b8acc65 | |||
a47829082e | |||
48ab5fcf08 | |||
dc8b60eeb1 | |||
ee67432ffc | |||
7755a6b655 | |||
d7f72819de | |||
2a4d7bf14f | |||
d49287922f | |||
ac0f6f40cf | |||
d91f947ab0 | |||
af71274ea9 | |||
0feba4b8d9 | |||
62f85293e2 | |||
6a048cee85 | |||
0d93612d16 | |||
9bf68b0d20 | |||
371f1dc451 | |||
5cb2ec6411 | |||
3723a1d8b8 | |||
4c30e9459d | |||
23d323073d | |||
0ad734262a | |||
0649f9fd2c | |||
d089662dab | |||
8c1c336fc6 | |||
43b4f14ace | |||
3717e38845 | |||
265d4d0450 | |||
726e727c7d | |||
cb664774c0 | |||
b89bf1d5e8 | |||
53ce37a83a | |||
e9ac9057ff | |||
7020fc2a93 | |||
efcd9539dd | |||
61ecc48d0e | |||
e465f1b791 | |||
01b6c14bcc | |||
34b02210df | |||
0034776b34 | |||
b183c45027 | |||
7d68905f1b | |||
0953c072fe | |||
d152187ee8 | |||
3c5affce88 | |||
f27e21f9a0 | |||
337ca328c3 | |||
beb9e2c43f | |||
4d79df90a7 | |||
aa72d9b730 | |||
80e899a5d3 | |||
7c33120546 | |||
7f3c86038f | |||
c1446f8559 | |||
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f |
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,13 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
**Important Notice**
|
||||||
|
|
||||||
|
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
|
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
|
||||||
|
|
||||||
|
Thank you for your understanding and cooperation!
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- Cloud or Self-hosted
|
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
|
- Cloud or Self-hosted
|
||||||
|
- Experimental Features enabled or disabled
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
/.nx/cache
|
/.nx/cache
|
||||||
|
|
||||||
|
# Issue: https://github.com/prettier/prettier/issues/15650
|
||||||
|
/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
|
||||||
|
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
145
CHANGELOG.md
145
CHANGELOG.md
@ -5,6 +5,125 @@ 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).
|
||||||
|
|
||||||
|
## 2.37.0 - 2024-01-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the chart size in the asset profile details dialog of the admin control
|
||||||
|
- Updated the `docker compose` instructions to _Compose V2_ in the documentation
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the hidden fifth tab on mobile
|
||||||
|
|
||||||
|
## 2.36.0 - 2024-01-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the assistant by a tag selector (experimental)
|
||||||
|
- Added support to set a _CoinGecko_ Demo API key via environment variable (`API_KEY_COINGECKO_DEMO`)
|
||||||
|
- Added support to set a _CoinGecko_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Removed the `AccountType` enum
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
|
||||||
|
## 2.35.0 - 2024-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to grant private access
|
||||||
|
- Added a hint for _Time-Weighted Rate of Return_ (TWR) to the portfolio summary tab on the home page
|
||||||
|
- Added support for REST APIs (`JSON`) via the scraper configuration
|
||||||
|
- Enabled the _Redis_ authentication in the `docker-compose` files
|
||||||
|
- Set up a git-hook to format the code before any commit
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the user interface of the access table to share the portfolio
|
||||||
|
- Improved the style of the assistant (experimental)
|
||||||
|
|
||||||
|
## 2.34.0 - 2024-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the assistant by a date range selector (experimental)
|
||||||
|
- Added a button to test the scraper configuration in the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style of the _Top 3_ and _Bottom 3_ performers on the analysis page
|
||||||
|
- Upgraded `Nx` from version `17.2.7` to `17.2.8`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the time-weighted performance calculation for `1D`
|
||||||
|
- Improved the tabs on iOS (_Add to Home Screen_)
|
||||||
|
|
||||||
|
## 2.33.0 - 2023-12-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to edit the currency of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
|
||||||
|
- Added a hint for the community languages in the user settings
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the performance calculation to a time-weighted approach
|
||||||
|
- Normalized the benchmark by currency in the benchmark comparator
|
||||||
|
- Increased the timeout to load currencies in the exchange rate data service
|
||||||
|
- Exposed the environment variable `REQUEST_TIMEOUT`
|
||||||
|
- Used the `HasPermission` annotation in endpoints
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.9.0` to `2.9.1`
|
||||||
|
- Upgraded `Nx` from version `17.2.5` to `17.2.7`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the handling of derived currencies (`USX`)
|
||||||
|
|
||||||
|
## 2.32.0 - 2023-12-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to search for an asset profile by `id` as an administrator
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set the select column of the lazy-loaded activities table to stick at the end (experimental)
|
||||||
|
- Dropped the activity id in the activities import
|
||||||
|
- Improved the validation of the currency management in the admin control panel
|
||||||
|
- Improved the performance of the value redaction interceptor for the impersonation mode by eliminating `cloneDeep`
|
||||||
|
- Modernized the `Nx` executors
|
||||||
|
- `@nx/eslint:lint`
|
||||||
|
- `@nx/webpack:webpack`
|
||||||
|
- Upgraded `prettier` from version `3.1.0` to `3.1.1`
|
||||||
|
- Upgraded `prisma` from version `5.7.0` to `5.7.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reset the letter spacing in buttons
|
||||||
|
|
||||||
|
## 2.31.0 - 2023-12-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced the lazy-loaded activities table to the account detail dialog (experimental)
|
||||||
|
- Introduced the lazy-loaded activities table to the import activities dialog (experimental)
|
||||||
|
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
|
||||||
|
- Improved the font weight in the value component
|
||||||
|
- Improved the language localization for Türkçe (`tr`)
|
||||||
|
- Upgraded `angular` from version `17.0.4` to `17.0.7`
|
||||||
|
- Upgraded to _Inter_ 4 font family
|
||||||
|
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the loading state in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||||
|
- Fixed the edit of activity in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||||
|
|
||||||
## 2.30.0 - 2023-12-12
|
## 2.30.0 - 2023-12-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -264,7 +383,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Changed the users table in the admin control panel to an `@angular/material` data table
|
- Changed the users table in the admin control panel to an `@angular/material` data table
|
||||||
- Improved the styling of the membership status
|
- Improved the style of the membership status
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -1439,7 +1558,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the styling in the admin control panel
|
- Improved the style in the admin control panel
|
||||||
- Removed the _Google Play_ badge from the landing page
|
- Removed the _Google Play_ badge from the landing page
|
||||||
- Upgraded `eslint` dependencies
|
- Upgraded `eslint` dependencies
|
||||||
|
|
||||||
@ -2194,7 +2313,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Simplified the initialization of the exchange rate service
|
- Simplified the initialization of the exchange rate service
|
||||||
- Improved the orders query for `assetClass` with symbol profile overrides
|
- Improved the orders query for `assetClass` with symbol profile overrides
|
||||||
- Improved the styling of the benchmarks in the markets overview
|
- Improved the style of the benchmarks in the markets overview
|
||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
@ -2528,7 +2647,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed a styling issue in the benchmark component on mobile
|
- Fixed a style issue in the benchmark component on mobile
|
||||||
|
|
||||||
## 1.152.0 - 26.05.2022
|
## 1.152.0 - 26.05.2022
|
||||||
|
|
||||||
@ -3135,7 +3254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed the styling in the footer row of the activities table
|
- Fixed the style in the footer row of the activities table
|
||||||
|
|
||||||
## 1.106.0 - 23.01.2022
|
## 1.106.0 - 23.01.2022
|
||||||
|
|
||||||
@ -3903,7 +4022,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the wording for the _Restricted View_: _Presenter View_
|
- Improved the wording for the _Restricted View_: _Presenter View_
|
||||||
- Improved the styling of the tables
|
- Improved the style of the tables
|
||||||
- Ignored cash assets in the allocation chart by sector, continent and country
|
- Ignored cash assets in the allocation chart by sector, continent and country
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@ -4106,8 +4225,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the styling of the current pricing plan
|
- Improved the style of the current pricing plan
|
||||||
- Improved the styling of the transaction type badge
|
- Improved the style of the transaction type badge
|
||||||
- Set the public _Stripe_ key dynamically
|
- Set the public _Stripe_ key dynamically
|
||||||
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||||
|
|
||||||
@ -4467,7 +4586,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the users table styling of the admin control panel
|
- Improved the users table style of the admin control panel
|
||||||
- Improved the background colors in the dark mode
|
- Improved the background colors in the dark mode
|
||||||
|
|
||||||
## 0.92.0 - 25.04.2021
|
## 0.92.0 - 25.04.2021
|
||||||
@ -4491,7 +4610,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the styling of the rules in the _X-ray_ section
|
- Improved the style of the rules in the _X-ray_ section
|
||||||
|
|
||||||
## 0.90.0 - 22.04.2021
|
## 0.90.0 - 22.04.2021
|
||||||
|
|
||||||
@ -4686,7 +4805,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the alignment of the _Why Ghostfolio?_ section
|
- Improved the alignment of the _Why Ghostfolio?_ section
|
||||||
- Improved the styling of the _Fear & Greed Index_ (market mood)
|
- Improved the style of the _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
## 0.73.0 - 31.03.2021
|
## 0.73.0 - 31.03.2021
|
||||||
|
|
||||||
@ -4732,7 +4851,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the styling in the _X-ray_ section
|
- Improved the style in the _X-ray_ section
|
||||||
|
|
||||||
## 0.70.0 - 27.03.2021
|
## 0.70.0 - 27.03.2021
|
||||||
|
|
||||||
@ -5027,7 +5146,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Only show relevant data in the position detail dialog
|
- Only show relevant data in the position detail dialog
|
||||||
- Improved the performance chart styling in Safari
|
- Improved the performance chart style in Safari
|
||||||
|
|
||||||
## 0.40.0 - 01.03.2021
|
## 0.40.0 - 01.03.2021
|
||||||
|
|
||||||
|
46
README.md
46
README.md
@ -49,7 +49,7 @@ 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 for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) 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
|
||||||
@ -87,19 +87,22 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### 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 |
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -115,7 +118,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
@ -123,8 +126,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
|||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
@ -135,7 +138,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
At each start, the container will automatically apply the database schema migrations if needed.
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Home Server Systems (Community)
|
### Home Server Systems (Community)
|
||||||
@ -155,8 +158,9 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
|
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Open http://localhost:4200/en in your browser
|
1. Open http://localhost:4200/en in your browser
|
||||||
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`)
|
||||||
@ -165,7 +169,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
|||||||
|
|
||||||
#### Debug
|
#### Debug
|
||||||
|
|
||||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
|
|
||||||
@ -278,6 +282,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -7,14 +7,15 @@
|
|||||||
"generators": {},
|
"generators": {},
|
||||||
"targets": {
|
"targets": {
|
||||||
"build": {
|
"build": {
|
||||||
"executor": "@nrwl/webpack:webpack",
|
"executor": "@nx/webpack:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/apps/api",
|
"outputPath": "dist/apps/api",
|
||||||
"main": "apps/api/src/main.ts",
|
"main": "apps/api/src/main.ts",
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
"assets": ["apps/api/src/assets"],
|
"assets": ["apps/api/src/assets"],
|
||||||
"target": "node",
|
"target": "node",
|
||||||
"compiler": "tsc"
|
"compiler": "tsc",
|
||||||
|
"webpackConfig": "apps/api/webpack.config.js"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@ -39,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nrwl/linter:eslint",
|
"executor": "@nx/eslint:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -24,11 +27,12 @@ import { CreateAccessDto } from './create-access.dto';
|
|||||||
export class AccessController {
|
export class AccessController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getAllAccesses(): Promise<Access[]> {
|
public async getAllAccesses(): Promise<Access[]> {
|
||||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||||
include: {
|
include: {
|
||||||
@ -57,13 +61,15 @@ export class AccessController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccess)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createAccess(
|
public async createAccess(
|
||||||
@Body() data: CreateAccessDto
|
@Body() data: CreateAccessDto
|
||||||
): Promise<AccessModel> {
|
): Promise<AccessModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -71,25 +77,29 @@ export class AccessController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.createAccess({
|
try {
|
||||||
alias: data.alias || undefined,
|
return await this.accessService.createAccess({
|
||||||
GranteeUser: data.granteeUserId
|
alias: data.alias || undefined,
|
||||||
? { connect: { id: data.granteeUserId } }
|
GranteeUser: data.granteeUserId
|
||||||
: undefined,
|
? { connect: { id: data.granteeUserId } }
|
||||||
User: { connect: { id: this.request.user.id } }
|
: undefined,
|
||||||
});
|
User: { connect: { id: this.request.user.id } }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAccess)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||||
const access = await this.accessService.access({ id });
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (
|
if (!access || access.userId !== this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
|
||||||
!access ||
|
|
||||||
access.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [PrismaModule],
|
imports: [ConfigurationModule, PrismaModule],
|
||||||
providers: [AccessService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccessDto {
|
export class CreateAccessDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -6,7 +6,7 @@ export class CreateAccessDto {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsUUID()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -8,11 +11,11 @@ import {
|
|||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AccountBalanceService } from './account-balance.service';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
import { AccountBalance } from '@prisma/client';
|
import { AccountBalance } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
|
||||||
@Controller('account-balance')
|
@Controller('account-balance')
|
||||||
export class AccountBalanceController {
|
export class AccountBalanceController {
|
||||||
@ -21,8 +24,9 @@ export class AccountBalanceController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.deleteAccountBalance)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
@ -30,14 +34,7 @@ export class AccountBalanceController {
|
|||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.deleteAccountBalance
|
|
||||||
) ||
|
|
||||||
!accountBalance ||
|
|
||||||
accountBalance.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
@ -7,7 +9,7 @@ import {
|
|||||||
AccountBalancesResponse,
|
AccountBalancesResponse,
|
||||||
Accounts
|
Accounts
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -47,17 +49,9 @@ export class AccountController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAccount)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await this.accountService.accountWithOrders(
|
const account = await this.accountService.accountWithOrders(
|
||||||
{
|
{
|
||||||
id_userId: {
|
id_userId: {
|
||||||
@ -87,7 +81,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
@ -102,7 +96,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@ -122,7 +116,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/balances')
|
@Get(':id/balances')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountBalancesById(
|
public async getAccountBalancesById(
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
@ -133,20 +127,12 @@ export class AccountController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccount)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
@Body() data: CreateAccountDto
|
@Body() data: CreateAccountDto
|
||||||
): Promise<AccountModel> {
|
): Promise<AccountModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.platformId) {
|
if (data.platformId) {
|
||||||
const platformId = data.platformId;
|
const platformId = data.platformId;
|
||||||
delete data.platformId;
|
delete data.platformId;
|
||||||
@ -172,20 +158,12 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateAccount)
|
||||||
@Post('transfer-balance')
|
@Post('transfer-balance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async transferAccountBalance(
|
public async transferAccountBalance(
|
||||||
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountsOfUser = await this.accountService.getAccounts(
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
@ -234,18 +212,10 @@ export class AccountController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateAccount)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalAccount = await this.accountService.account({
|
const originalAccount = await this.accountService.account({
|
||||||
id_userId: {
|
id_userId: {
|
||||||
id,
|
id,
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
@ -17,7 +20,7 @@ import {
|
|||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
EnhancedSymbolProfile
|
EnhancedSymbolProfile
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
MarketDataPreset,
|
MarketDataPreset,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -29,6 +32,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@ -54,61 +58,29 @@ export class AdminController {
|
|||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly manualService: ManualService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getAdminData(): Promise<AdminData> {
|
public async getAdminData(): Promise<AdminData> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather')
|
@Post('gather')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gather7Days(): Promise<void> {
|
public async gather7Days(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataGatheringService.gather7Days();
|
this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
@ -130,21 +102,10 @@ export class AdminController {
|
|||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
@ -164,24 +125,13 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileDataForSymbol(
|
public async gatherProfileDataForSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue({
|
await this.dataGatheringService.addJobToQueue({
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -196,47 +146,25 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/:dataSource/:symbol/:dateString')
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<MarketData> {
|
): Promise<MarketData> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
@ -254,7 +182,8 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
@Query('presetId') presetId?: MarketDataPreset,
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
@ -264,18 +193,6 @@ export class AdminController {
|
|||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAssetSubClasses,
|
filterByAssetSubClasses,
|
||||||
filterBySearchQuery
|
filterBySearchQuery
|
||||||
@ -292,45 +209,47 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@Post('market-data/:dataSource/:symbol/test')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async testMarketData(
|
||||||
|
@Body() data: { scraperConfiguration: string },
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<{ price: number }> {
|
||||||
|
try {
|
||||||
|
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
|
||||||
|
const price = await this.manualService.test(scraperConfiguration);
|
||||||
|
|
||||||
|
if (price) {
|
||||||
|
return { price };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not parse the current market price');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
|
||||||
|
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('market-data/:dataSource/:symbol')
|
@Post('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateMarketData(
|
public async updateMarketData(
|
||||||
@Body() data: UpdateBulkMarketDataDto,
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
({ date, marketPrice }) => ({
|
({ date, marketPrice }) => ({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -349,26 +268,15 @@ export class AdminController {
|
|||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async update(
|
public async update(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Body() data: UpdateMarketDataDto
|
@Body() data: UpdateMarketDataDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
@ -383,24 +291,14 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('profile-data/:dataSource/:symbol')
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async addProfileData(
|
public async addProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<SymbolProfile | never> {
|
): Promise<SymbolProfile | never> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.adminService.addAssetProfile({
|
return this.adminService.addAssetProfile({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
@ -409,45 +307,23 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Patch('profile-data/:dataSource/:symbol')
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async patchAssetProfileData(
|
public async patchAssetProfileData(
|
||||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<EnhancedSymbolProfile> {
|
): Promise<EnhancedSymbolProfile> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.patchAssetProfileData({
|
return this.adminService.patchAssetProfileData({
|
||||||
...assetProfileData,
|
...assetProfileData,
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -455,24 +331,13 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.adminService.putSetting(key, data.value);
|
return await this.adminService.putSetting(key, data.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -176,6 +176,7 @@ export class AdminService {
|
|||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
|
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
@ -320,6 +321,7 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
@ -330,6 +332,7 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
|
@ -1,87 +1,48 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { JobStatus } from 'bull';
|
import { JobStatus } from 'bull';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { QueueService } from './queue.service';
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
@Controller('admin/queue')
|
@Controller('admin/queue')
|
||||||
export class QueueController {
|
export class QueueController {
|
||||||
public constructor(
|
public constructor(private readonly queueService: QueueService) {}
|
||||||
private readonly queueService: QueueService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Delete('job')
|
@Delete('job')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteJobs(
|
public async deleteJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.deleteJobs({ status });
|
return this.queueService.deleteJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job')
|
@Get('job')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getJobs(
|
public async getJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<AdminJobs> {
|
): Promise<AdminJobs> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.getJobs({ status });
|
return this.queueService.getJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('job/:id')
|
@Delete('job/:id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queueService.deleteJob(id);
|
return this.queueService.deleteJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,10 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -1,40 +1,18 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
Controller,
|
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||||
Delete,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('auth-device')
|
@Controller('auth-device')
|
||||||
export class AuthDeviceController {
|
export class AuthDeviceController {
|
||||||
public constructor(
|
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
||||||
private readonly authDeviceService: AuthDeviceService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAuthDevice)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.deleteAuthDevice
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.authDeviceService.deleteAuthDevice({ id });
|
await this.authDeviceService.deleteAuthDevice({ id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
@ -118,13 +119,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-registration-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async generateRegistrationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
return this.webAuthService.generateRegistrationOptions();
|
return this.webAuthService.generateRegistrationOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/verify-attestation')
|
@Post('webauthn/verify-attestation')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
) {
|
) {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import type {
|
import type {
|
||||||
@ -5,7 +7,7 @@ import type {
|
|||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -33,21 +35,10 @@ export class BenchmarkController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -71,23 +62,12 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':dataSource/:symbol')
|
@Delete(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteBenchmark(
|
public async deleteBenchmark(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -120,7 +100,7 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@ -128,11 +108,13 @@ export class BenchmarkController {
|
|||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
const startDate = new Date(startDateString);
|
const startDate = new Date(startDateString);
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
startDate,
|
startDate,
|
||||||
symbol
|
symbol,
|
||||||
|
userCurrency
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
|||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
@ -11,7 +12,8 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
calculateBenchmarkTrend
|
calculateBenchmarkTrend,
|
||||||
|
parseDate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Benchmark,
|
Benchmark,
|
||||||
@ -21,11 +23,11 @@ import {
|
|||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, isSameDay, subDays } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { isNumber, last, uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -34,6 +36,7 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@ -203,8 +206,14 @@ export class BenchmarkService {
|
|||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
startDate,
|
startDate,
|
||||||
symbol
|
symbol,
|
||||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
userCurrency
|
||||||
|
}: {
|
||||||
|
startDate: Date;
|
||||||
|
userCurrency: string;
|
||||||
|
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
this.symbolService.get({
|
this.symbolService.get({
|
||||||
dataGatheringItem: {
|
dataGatheringItem: {
|
||||||
@ -226,44 +235,101 @@ export class BenchmarkService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
|
||||||
|
currencyFrom: currentSymbolItem.currency,
|
||||||
|
currencyTo: userCurrency,
|
||||||
|
dates: marketDataItems.map(({ date }) => {
|
||||||
|
return date;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchangeRateAtStartDate =
|
||||||
|
exchangeRates[format(startDate, DATE_FORMAT)];
|
||||||
|
|
||||||
|
if (!exchangeRateAtStartDate) {
|
||||||
|
Logger.error(
|
||||||
|
`No exchange rate has been found for ${
|
||||||
|
currentSymbolItem.currency
|
||||||
|
}${userCurrency} at ${format(startDate, DATE_FORMAT)}`,
|
||||||
|
'BenchmarkService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { marketData };
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
||||||
|
return isSameDay(date, startDate);
|
||||||
|
})?.marketPrice;
|
||||||
|
|
||||||
|
if (!marketPriceAtStartDate) {
|
||||||
|
Logger.error(
|
||||||
|
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
||||||
|
startDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
'BenchmarkService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { marketData };
|
||||||
|
}
|
||||||
|
|
||||||
const step = Math.round(
|
const step = Math.round(
|
||||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
let i = 0;
|
||||||
const response = {
|
|
||||||
marketData: [
|
|
||||||
...marketDataItems
|
|
||||||
.filter((marketDataItem, index) => {
|
|
||||||
return index % step === 0;
|
|
||||||
})
|
|
||||||
.map((marketDataItem) => {
|
|
||||||
return {
|
|
||||||
date: format(marketDataItem.date, DATE_FORMAT),
|
|
||||||
value:
|
|
||||||
marketPriceAtStartDate === 0
|
|
||||||
? 0
|
|
||||||
: this.calculateChangeInPercentage(
|
|
||||||
marketPriceAtStartDate,
|
|
||||||
marketDataItem.marketPrice
|
|
||||||
) * 100
|
|
||||||
};
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice) {
|
for (let marketDataItem of marketDataItems) {
|
||||||
response.marketData.push({
|
if (i % step !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeRate =
|
||||||
|
exchangeRates[format(marketDataItem.date, DATE_FORMAT)];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice * exchangeRateFactor
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const includesToday = isSameDay(
|
||||||
|
parseDate(last(marketData).date),
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSymbolItem?.marketPrice && !includesToday) {
|
||||||
|
const exchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
date: format(new Date(), DATE_FORMAT),
|
date: format(new Date(), DATE_FORMAT),
|
||||||
value:
|
value:
|
||||||
this.calculateChangeInPercentage(
|
this.calculateChangeInPercentage(
|
||||||
marketPriceAtStartDate,
|
marketPriceAtStartDate,
|
||||||
currentSymbolItem.marketPrice
|
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||||
) * 100
|
) * 100
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return {
|
||||||
|
marketData
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addBenchmark({
|
public async addBenchmark({
|
||||||
|
35
apps/api/src/app/cache/cache.controller.ts
vendored
35
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,39 +1,18 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
Controller,
|
import { Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Post,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||||
private readonly redisCacheService: RedisCacheService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.redisCacheService.reset();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -19,7 +20,7 @@ export class ExchangeRateController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':symbol/:dateString')
|
@Get(':symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getExchangeRate(
|
public async getExchangeRate(
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||||
@ -14,12 +15,13 @@ export class ExportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async export(
|
public async export(
|
||||||
@Query('activityIds') activityIds?: string[]
|
@Query('activityIds') activityIds?: string[]
|
||||||
): Promise<Export> {
|
): Promise<Export> {
|
||||||
return this.exportService.export({
|
return this.exportService.export({
|
||||||
activityIds,
|
activityIds,
|
||||||
|
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -13,9 +13,11 @@ export class ExportService {
|
|||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
const accounts = (
|
const accounts = (
|
||||||
@ -39,10 +41,13 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let activities = await this.orderService.orders({
|
let { activities } = await this.orderService.getOrders({
|
||||||
include: { SymbolProfile: true },
|
userCurrency,
|
||||||
orderBy: { date: 'desc' },
|
userId,
|
||||||
where: { userId }
|
includeDrafts: true,
|
||||||
|
sortColumn: 'date',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activityIds) {
|
if (activityIds) {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
@ -34,7 +36,8 @@ export class ImportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@HasPermission(permissions.createOrder)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@ -42,11 +45,7 @@ export class ImportController {
|
|||||||
@Query('dryRun') isDryRun?: boolean
|
@Query('dryRun') isDryRun?: boolean
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.createAccount
|
|
||||||
) ||
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -92,7 +91,7 @@ export class ImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends/:dataSource/:symbol')
|
@Get('dividends/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async gatherDividends(
|
public async gatherDividends(
|
||||||
|
@ -236,6 +236,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -459,15 +460,18 @@ export class ImportService {
|
|||||||
|
|
||||||
private async extendActivitiesWithErrors({
|
private async extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Partial<Activity>[]> {
|
}): Promise<Partial<Activity>[]> {
|
||||||
const existingActivities = await this.orderService.orders({
|
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||||
include: { SymbolProfile: true },
|
userCurrency,
|
||||||
orderBy: { date: 'desc' },
|
userId,
|
||||||
where: { userId }
|
includeDrafts: true,
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return activitiesDto.map(
|
return activitiesDto.map(
|
||||||
|
@ -8,7 +8,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
|||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
DEFAULT_REQUEST_TIMEOUT,
|
|
||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
@ -162,7 +161,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { pull_count } = await got(
|
const { pull_count } = await got(
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
@ -187,7 +186,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -214,7 +213,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { stargazers_count } = await got(
|
const { stargazers_count } = await got(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
@ -342,7 +341,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { data } = await got(
|
const { data } = await got(
|
||||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -9,6 +9,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class LogoService {
|
export class LogoService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ export class LogoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
return got(
|
return got(
|
||||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
@ -44,24 +46,16 @@ export class OrderController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete()
|
@Delete()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteOrder)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrders(): Promise<number> {
|
public async deleteOrders(): Promise<number> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orderService.deleteOrders({
|
return this.orderService.deleteOrders({
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
const order = await this.orderService.order({ id });
|
const order = await this.orderService.order({ id });
|
||||||
|
|
||||||
@ -82,7 +76,7 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@ -120,19 +114,11 @@ export class OrderController {
|
|||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createOrder)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = await this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
date: parseISO(data.date),
|
date: parseISO(data.date),
|
||||||
@ -170,19 +156,16 @@ export class OrderController {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateOrder)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
|
||||||
!originalOrder ||
|
|
||||||
originalOrder.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
|
|||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activities, Activity } from './interfaces/activities.interface';
|
import { Activities } from './interfaces/activities.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
@ -37,34 +37,6 @@ export class OrderService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async order(
|
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
|
||||||
): Promise<Order | null> {
|
|
||||||
return this.prismaService.order.findUnique({
|
|
||||||
where: orderWhereUniqueInput
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async orders(params: {
|
|
||||||
include?: Prisma.OrderInclude;
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
cursor?: Prisma.OrderWhereUniqueInput;
|
|
||||||
where?: Prisma.OrderWhereInput;
|
|
||||||
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
|
||||||
}): Promise<OrderWithAccount[]> {
|
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
|
||||||
|
|
||||||
return this.prismaService.order.findMany({
|
|
||||||
cursor,
|
|
||||||
include,
|
|
||||||
orderBy,
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
where
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ -379,6 +351,14 @@ export class OrderService {
|
|||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async order(
|
||||||
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
|
): Promise<Order | null> {
|
||||||
|
return this.prismaService.order.findUnique({
|
||||||
|
where: orderWhereUniqueInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async updateOrder({
|
public async updateOrder({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
@ -455,4 +435,24 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async orders(params: {
|
||||||
|
include?: Prisma.OrderInclude;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
cursor?: Prisma.OrderWhereUniqueInput;
|
||||||
|
where?: Prisma.OrderWhereInput;
|
||||||
|
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||||
|
}): Promise<OrderWithAccount[]> {
|
||||||
|
const { include, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
|
return this.prismaService.order.findMany({
|
||||||
|
cursor,
|
||||||
|
include,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Platform } from '@prisma/client';
|
import { Platform } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -23,49 +22,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
|
|||||||
|
|
||||||
@Controller('platform')
|
@Controller('platform')
|
||||||
export class PlatformController {
|
export class PlatformController {
|
||||||
public constructor(
|
public constructor(private readonly platformService: PlatformService) {}
|
||||||
private readonly platformService: PlatformService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPlatforms() {
|
public async getPlatforms() {
|
||||||
return this.platformService.getPlatformsWithAccountCount();
|
return this.platformService.getPlatformsWithAccountCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createPlatform)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createPlatform(
|
public async createPlatform(
|
||||||
@Body() data: CreatePlatformDto
|
@Body() data: CreatePlatformDto
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.platformService.createPlatform(data);
|
return this.platformService.createPlatform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updatePlatform)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updatePlatform(
|
public async updatePlatform(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() data: UpdatePlatformDto
|
@Body() data: UpdatePlatformDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
@ -88,17 +68,9 @@ export class PlatformController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deletePlatform)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deletePlatform(@Param('id') id: string) {
|
public async deletePlatform(@Param('id') id: string) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
@ -92,6 +92,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPrice: 148.9,
|
marketPrice: 148.9,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
timeWeightedInvestment: new Big('285.8'),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -81,6 +81,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPrice: 148.9,
|
marketPrice: 148.9,
|
||||||
quantity: new Big('2'),
|
quantity: new Big('2'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
timeWeightedInvestment: new Big('273.2'),
|
||||||
transactionCount: 1
|
transactionCount: 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
currentValue: new Big('13657.2'),
|
currentValue: new Big('13657.2'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('27172.74'),
|
netPerformance: new Big('27172.74'),
|
||||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('320.43'),
|
averagePrice: new Big('320.43'),
|
||||||
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
|
|||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
firstBuyDate: '2015-01-01',
|
firstBuyDate: '2015-01-01',
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
investment: new Big('320.43'),
|
investment: new Big('320.43'),
|
||||||
netPerformance: new Big('27172.74'),
|
netPerformance: new Big('27172.74'),
|
||||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
marketPrice: 13657.2,
|
marketPrice: 13657.2,
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
currentValue: new Big('87.8'),
|
currentValue: new Big('87.8'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('17.68'),
|
netPerformance: new Big('17.68'),
|
||||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('75.80'),
|
averagePrice: new Big('75.80'),
|
||||||
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
|
|||||||
fee: new Big('4.25'),
|
fee: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
investment: new Big('75.80'),
|
investment: new Big('75.80'),
|
||||||
netPerformance: new Big('17.68'),
|
netPerformance: new Big('17.68'),
|
||||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||||
marketPrice: 87.8,
|
marketPrice: 87.8,
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
|
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -112,6 +112,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPrice: 87.8,
|
marketPrice: 87.8,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
|
timeWeightedInvestment: new Big('151.6'),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
addMilliseconds,
|
addMilliseconds,
|
||||||
addMonths,
|
addMonths,
|
||||||
addYears,
|
addYears,
|
||||||
|
differenceInDays,
|
||||||
endOfDay,
|
endOfDay,
|
||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
@ -43,7 +44,7 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
|
|||||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||||
|
|
||||||
export class PortfolioCalculator {
|
export class PortfolioCalculator {
|
||||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT =
|
||||||
true;
|
true;
|
||||||
|
|
||||||
private static readonly ENABLE_LOGGING = false;
|
private static readonly ENABLE_LOGGING = false;
|
||||||
@ -238,12 +239,13 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const valuesByDate: {
|
const accumulatedValuesByDate: {
|
||||||
[date: string]: {
|
[date: string]: {
|
||||||
maxTotalInvestmentValue: Big;
|
maxTotalInvestmentValue: Big;
|
||||||
totalCurrentValue: Big;
|
totalCurrentValue: Big;
|
||||||
totalInvestmentValue: Big;
|
totalInvestmentValue: Big;
|
||||||
totalNetPerformanceValue: Big;
|
totalNetPerformanceValue: Big;
|
||||||
|
totalTimeWeightedInvestmentValue: Big;
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
@ -253,6 +255,7 @@ export class PortfolioCalculator {
|
|||||||
investmentValues: { [date: string]: Big };
|
investmentValues: { [date: string]: Big };
|
||||||
maxInvestmentValues: { [date: string]: Big };
|
maxInvestmentValues: { [date: string]: Big };
|
||||||
netPerformanceValues: { [date: string]: Big };
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
timeWeightedInvestmentValues: { [date: string]: Big };
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
@ -261,7 +264,8 @@ export class PortfolioCalculator {
|
|||||||
currentValues,
|
currentValues,
|
||||||
investmentValues,
|
investmentValues,
|
||||||
maxInvestmentValues,
|
maxInvestmentValues,
|
||||||
netPerformanceValues
|
netPerformanceValues,
|
||||||
|
timeWeightedInvestmentValues
|
||||||
} = this.getSymbolMetrics({
|
} = this.getSymbolMetrics({
|
||||||
end,
|
end,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
@ -275,7 +279,8 @@ export class PortfolioCalculator {
|
|||||||
currentValues,
|
currentValues,
|
||||||
investmentValues,
|
investmentValues,
|
||||||
maxInvestmentValues,
|
maxInvestmentValues,
|
||||||
netPerformanceValues
|
netPerformanceValues,
|
||||||
|
timeWeightedInvestmentValues
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,38 +298,50 @@ export class PortfolioCalculator {
|
|||||||
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
const netPerformanceValue =
|
const netPerformanceValue =
|
||||||
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||||
|
const timeWeightedInvestmentValue =
|
||||||
|
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
valuesByDate[dateString] = {
|
accumulatedValuesByDate[dateString] = {
|
||||||
totalCurrentValue: (
|
totalCurrentValue: (
|
||||||
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||||
).add(currentValue),
|
).add(currentValue),
|
||||||
totalInvestmentValue: (
|
totalInvestmentValue: (
|
||||||
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
|
||||||
|
new Big(0)
|
||||||
).add(investmentValue),
|
).add(investmentValue),
|
||||||
|
totalTimeWeightedInvestmentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalTimeWeightedInvestmentValue ?? new Big(0)
|
||||||
|
).add(timeWeightedInvestmentValue),
|
||||||
maxTotalInvestmentValue: (
|
maxTotalInvestmentValue: (
|
||||||
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ??
|
||||||
|
new Big(0)
|
||||||
).add(maxInvestmentValue),
|
).add(maxInvestmentValue),
|
||||||
totalNetPerformanceValue: (
|
totalNetPerformanceValue: (
|
||||||
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
|
||||||
|
new Big(0)
|
||||||
).add(netPerformanceValue)
|
).add(netPerformanceValue)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(valuesByDate).map(([date, values]) => {
|
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
|
||||||
const {
|
const {
|
||||||
maxTotalInvestmentValue,
|
maxTotalInvestmentValue,
|
||||||
totalCurrentValue,
|
totalCurrentValue,
|
||||||
totalInvestmentValue,
|
totalInvestmentValue,
|
||||||
totalNetPerformanceValue
|
totalNetPerformanceValue,
|
||||||
|
totalTimeWeightedInvestmentValue
|
||||||
} = values;
|
} = values;
|
||||||
|
|
||||||
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
let investmentValue =
|
||||||
|
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
|
||||||
|
? totalTimeWeightedInvestmentValue
|
||||||
|
: maxTotalInvestmentValue;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = investmentValue.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: totalNetPerformanceValue
|
: totalNetPerformanceValue.div(investmentValue).mul(100).toNumber();
|
||||||
.div(maxTotalInvestmentValue)
|
|
||||||
.mul(100)
|
|
||||||
.toNumber();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
@ -447,7 +464,6 @@ export class PortfolioCalculator {
|
|||||||
if (firstIndex > 0) {
|
if (firstIndex > 0) {
|
||||||
firstIndex--;
|
firstIndex--;
|
||||||
}
|
}
|
||||||
const initialValues: { [symbol: string]: Big } = {};
|
|
||||||
|
|
||||||
const positions: TimelinePosition[] = [];
|
const positions: TimelinePosition[] = [];
|
||||||
let hasAnySymbolMetricsErrors = false;
|
let hasAnySymbolMetricsErrors = false;
|
||||||
@ -461,9 +477,9 @@ export class PortfolioCalculator {
|
|||||||
grossPerformance,
|
grossPerformance,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
initialValue,
|
|
||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercentage
|
netPerformancePercentage,
|
||||||
|
timeWeightedInvestment
|
||||||
} = this.getSymbolMetrics({
|
} = this.getSymbolMetrics({
|
||||||
end,
|
end,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
@ -472,9 +488,9 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
|
|
||||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||||
initialValues[item.symbol] = initialValue;
|
|
||||||
|
|
||||||
positions.push({
|
positions.push({
|
||||||
|
timeWeightedInvestment,
|
||||||
averagePrice: item.quantity.eq(0)
|
averagePrice: item.quantity.eq(0)
|
||||||
? new Big(0)
|
? new Big(0)
|
||||||
: item.investment.div(item.quantity),
|
: item.investment.div(item.quantity),
|
||||||
@ -509,7 +525,7 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
const overall = this.calculateOverallPerformance(positions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...overall,
|
...overall,
|
||||||
@ -732,18 +748,13 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateOverallPerformance(
|
private calculateOverallPerformance(positions: TimelinePosition[]) {
|
||||||
positions: TimelinePosition[],
|
|
||||||
initialValues: { [symbol: string]: Big }
|
|
||||||
) {
|
|
||||||
let currentValue = new Big(0);
|
let currentValue = new Big(0);
|
||||||
let grossPerformance = new Big(0);
|
let grossPerformance = new Big(0);
|
||||||
let grossPerformancePercentage = new Big(0);
|
|
||||||
let hasErrors = false;
|
let hasErrors = false;
|
||||||
let netPerformance = new Big(0);
|
let netPerformance = new Big(0);
|
||||||
let netPerformancePercentage = new Big(0);
|
|
||||||
let sumOfWeights = new Big(0);
|
|
||||||
let totalInvestment = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
|
let totalTimeWeightedInvestment = new Big(0);
|
||||||
|
|
||||||
for (const currentPosition of positions) {
|
for (const currentPosition of positions) {
|
||||||
if (currentPosition.marketPrice) {
|
if (currentPosition.marketPrice) {
|
||||||
@ -766,21 +777,9 @@ export class PortfolioCalculator {
|
|||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentPosition.grossPerformancePercentage) {
|
if (currentPosition.timeWeightedInvestment) {
|
||||||
// Use the average from the initial value and the current investment as
|
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
|
||||||
// a weight
|
currentPosition.timeWeightedInvestment
|
||||||
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
|
||||||
.plus(currentPosition.investment)
|
|
||||||
.div(2);
|
|
||||||
|
|
||||||
sumOfWeights = sumOfWeights.plus(weight);
|
|
||||||
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
|
||||||
currentPosition.grossPerformancePercentage.mul(weight)
|
|
||||||
);
|
|
||||||
|
|
||||||
netPerformancePercentage = netPerformancePercentage.plus(
|
|
||||||
currentPosition.netPerformancePercentage.mul(weight)
|
|
||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
@ -791,22 +790,18 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sumOfWeights.gt(0)) {
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
|
||||||
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
|
||||||
} else {
|
|
||||||
grossPerformancePercentage = new Big(0);
|
|
||||||
netPerformancePercentage = new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentValue,
|
currentValue,
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
hasErrors,
|
||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercentage,
|
totalInvestment,
|
||||||
totalInvestment
|
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: netPerformance.div(totalTimeWeightedInvestment),
|
||||||
|
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: grossPerformance.div(totalTimeWeightedInvestment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1018,6 +1013,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
let averagePriceAtEndDate = new Big(0);
|
let averagePriceAtEndDate = new Big(0);
|
||||||
let averagePriceAtStartDate = new Big(0);
|
let averagePriceAtStartDate = new Big(0);
|
||||||
|
const currentValues: { [date: string]: Big } = {};
|
||||||
let feesAtStartDate = new Big(0);
|
let feesAtStartDate = new Big(0);
|
||||||
let fees = new Big(0);
|
let fees = new Big(0);
|
||||||
let grossPerformance = new Big(0);
|
let grossPerformance = new Big(0);
|
||||||
@ -1025,12 +1021,12 @@ export class PortfolioCalculator {
|
|||||||
let grossPerformanceFromSells = new Big(0);
|
let grossPerformanceFromSells = new Big(0);
|
||||||
let initialValue: Big;
|
let initialValue: Big;
|
||||||
let investmentAtStartDate: Big;
|
let investmentAtStartDate: Big;
|
||||||
const currentValues: { [date: string]: Big } = {};
|
|
||||||
const investmentValues: { [date: string]: Big } = {};
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
let maxTotalInvestment = new Big(0);
|
let maxTotalInvestment = new Big(0);
|
||||||
const netPerformanceValues: { [date: string]: Big } = {};
|
const netPerformanceValues: { [date: string]: Big } = {};
|
||||||
|
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
|
||||||
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);
|
||||||
@ -1122,6 +1118,9 @@ export class PortfolioCalculator {
|
|||||||
return order.itemType === 'end';
|
return order.itemType === 'end';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let totalInvestmentDays = 0;
|
||||||
|
let sumOfTimeWeightedInvestments = new Big(0);
|
||||||
|
|
||||||
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];
|
||||||
|
|
||||||
@ -1162,11 +1161,11 @@ export class PortfolioCalculator {
|
|||||||
order.type === 'BUY'
|
order.type === 'BUY'
|
||||||
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
: totalUnits.gt(0)
|
: totalUnits.gt(0)
|
||||||
? totalInvestment
|
? totalInvestment
|
||||||
.div(totalUnits)
|
.div(totalUnits)
|
||||||
.mul(order.quantity)
|
.mul(order.quantity)
|
||||||
.mul(this.getFactor(order.type))
|
.mul(this.getFactor(order.type))
|
||||||
: new Big(0);
|
: new Big(0);
|
||||||
|
|
||||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
console.log('totalInvestment', totalInvestment.toNumber());
|
console.log('totalInvestment', totalInvestment.toNumber());
|
||||||
@ -1174,6 +1173,7 @@ export class PortfolioCalculator {
|
|||||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalInvestmentBeforeTransaction = totalInvestment;
|
||||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||||
|
|
||||||
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
||||||
@ -1243,14 +1243,51 @@ export class PortfolioCalculator {
|
|||||||
grossPerformanceAtStartDate = grossPerformance;
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isChartMode && i > indexOfStartOrder) {
|
if (i > indexOfStartOrder) {
|
||||||
currentValues[order.date] = valueOfInvestment;
|
// Only consider periods with an investment for the calculation of
|
||||||
netPerformanceValues[order.date] = grossPerformance
|
// the time weighted investment
|
||||||
.minus(grossPerformanceAtStartDate)
|
if (valueOfInvestmentBeforeTransaction.gt(0)) {
|
||||||
.minus(fees.minus(feesAtStartDate));
|
// Calculate the number of days since the previous order
|
||||||
|
const orderDate = new Date(order.date);
|
||||||
|
const previousOrderDate = new Date(orders[i - 1].date);
|
||||||
|
|
||||||
investmentValues[order.date] = totalInvestment;
|
let daysSinceLastOrder = differenceInDays(
|
||||||
maxInvestmentValues[order.date] = maxTotalInvestment;
|
orderDate,
|
||||||
|
previousOrderDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set to at least 1 day, otherwise the transactions on the same day
|
||||||
|
// would not be considered in the time weighted calculation
|
||||||
|
if (daysSinceLastOrder <= 0) {
|
||||||
|
daysSinceLastOrder = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum up the total investment days since the start date to calculate
|
||||||
|
// the time weighted investment
|
||||||
|
totalInvestmentDays += daysSinceLastOrder;
|
||||||
|
|
||||||
|
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
|
||||||
|
valueAtStartDate
|
||||||
|
.minus(investmentAtStartDate)
|
||||||
|
.plus(totalInvestmentBeforeTransaction)
|
||||||
|
.mul(daysSinceLastOrder)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChartMode) {
|
||||||
|
currentValues[order.date] = valueOfInvestment;
|
||||||
|
netPerformanceValues[order.date] = grossPerformance
|
||||||
|
.minus(grossPerformanceAtStartDate)
|
||||||
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
|
investmentValues[order.date] = totalInvestment;
|
||||||
|
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||||
|
|
||||||
|
timeWeightedInvestmentValues[order.date] =
|
||||||
|
totalInvestmentDays > 0
|
||||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||||
|
: new Big(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
@ -1274,50 +1311,79 @@ export class PortfolioCalculator {
|
|||||||
.minus(grossPerformanceAtStartDate)
|
.minus(grossPerformanceAtStartDate)
|
||||||
.minus(fees.minus(feesAtStartDate));
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
|
||||||
|
totalInvestmentDays > 0
|
||||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
||||||
maxTotalInvestment.minus(investmentAtStartDate)
|
maxTotalInvestment.minus(investmentAtStartDate)
|
||||||
);
|
);
|
||||||
|
|
||||||
const grossPerformancePercentage =
|
let grossPerformancePercentage: Big;
|
||||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
|
||||||
averagePriceAtStartDate.eq(0) ||
|
if (
|
||||||
averagePriceAtEndDate.eq(0) ||
|
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
|
||||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
) {
|
||||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
grossPerformancePercentage =
|
||||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
: new Big(0)
|
? totalGrossPerformance.div(
|
||||||
: // This formula has the issue that buying more units with a price
|
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||||
// lower than the average buying price results in a positive
|
|
||||||
// performance even if the market price stays constant
|
|
||||||
unitPriceAtEndDate
|
|
||||||
.div(averagePriceAtEndDate)
|
|
||||||
.div(
|
|
||||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
|
||||||
)
|
)
|
||||||
.minus(1);
|
: new Big(0);
|
||||||
|
} else {
|
||||||
|
grossPerformancePercentage =
|
||||||
|
averagePriceAtStartDate.eq(0) ||
|
||||||
|
averagePriceAtEndDate.eq(0) ||
|
||||||
|
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||||
|
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
|
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||||
|
: new Big(0)
|
||||||
|
: // This formula has the issue that buying more units with a price
|
||||||
|
// lower than the average buying price results in a positive
|
||||||
|
// performance even if the market price stays constant
|
||||||
|
unitPriceAtEndDate
|
||||||
|
.div(averagePriceAtEndDate)
|
||||||
|
.div(
|
||||||
|
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||||
|
)
|
||||||
|
.minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
const feesPerUnit = totalUnits.gt(0)
|
const feesPerUnit = totalUnits.gt(0)
|
||||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||||
: new Big(0);
|
: new Big(0);
|
||||||
|
|
||||||
const netPerformancePercentage =
|
let netPerformancePercentage: Big;
|
||||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
|
||||||
averagePriceAtStartDate.eq(0) ||
|
if (
|
||||||
averagePriceAtEndDate.eq(0) ||
|
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
|
||||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
) {
|
||||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
netPerformancePercentage =
|
||||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
: new Big(0)
|
? totalNetPerformance.div(
|
||||||
: // This formula has the issue that buying more units with a price
|
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||||
// lower than the average buying price results in a positive
|
|
||||||
// performance even if the market price stays constant
|
|
||||||
unitPriceAtEndDate
|
|
||||||
.minus(feesPerUnit)
|
|
||||||
.div(averagePriceAtEndDate)
|
|
||||||
.div(
|
|
||||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
|
||||||
)
|
)
|
||||||
.minus(1);
|
: new Big(0);
|
||||||
|
} else {
|
||||||
|
netPerformancePercentage =
|
||||||
|
averagePriceAtStartDate.eq(0) ||
|
||||||
|
averagePriceAtEndDate.eq(0) ||
|
||||||
|
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||||
|
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
|
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||||
|
: new Big(0)
|
||||||
|
: // This formula has the issue that buying more units with a price
|
||||||
|
// lower than the average buying price results in a positive
|
||||||
|
// performance even if the market price stays constant
|
||||||
|
unitPriceAtEndDate
|
||||||
|
.minus(feesPerUnit)
|
||||||
|
.div(averagePriceAtEndDate)
|
||||||
|
.div(
|
||||||
|
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||||
|
)
|
||||||
|
.minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
console.log(
|
console.log(
|
||||||
@ -1330,6 +1396,9 @@ export class PortfolioCalculator {
|
|||||||
2
|
2
|
||||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||||
Total investment: ${totalInvestment.toFixed(2)}
|
Total investment: ${totalInvestment.toFixed(2)}
|
||||||
|
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
|
||||||
|
2
|
||||||
|
)}
|
||||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||||
Gross performance: ${totalGrossPerformance.toFixed(
|
Gross performance: ${totalGrossPerformance.toFixed(
|
||||||
2
|
2
|
||||||
@ -1349,9 +1418,12 @@ export class PortfolioCalculator {
|
|||||||
maxInvestmentValues,
|
maxInvestmentValues,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
netPerformanceValues,
|
netPerformanceValues,
|
||||||
|
timeWeightedInvestmentValues,
|
||||||
grossPerformance: totalGrossPerformance,
|
grossPerformance: totalGrossPerformance,
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
netPerformance: totalNetPerformance
|
netPerformance: totalNetPerformance,
|
||||||
|
timeWeightedInvestment:
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
@ -61,7 +62,7 @@ export class PortfolioController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@ -204,7 +205,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends')
|
@Get('dividends')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getDividends(
|
public async getDividends(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@ -254,7 +255,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@ -315,7 +316,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@ -405,7 +406,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@ -500,7 +501,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@ -523,7 +524,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
|
@ -1035,25 +1035,44 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasErrors: currentPositions.hasErrors,
|
hasErrors: currentPositions.hasErrors,
|
||||||
positions: positions.map((position) => {
|
positions: positions.map(
|
||||||
return {
|
({
|
||||||
...position,
|
averagePrice,
|
||||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
currency,
|
||||||
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
|
dataSource,
|
||||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
firstBuyDate,
|
||||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
investment,
|
||||||
grossPerformancePercentage:
|
grossPerformance,
|
||||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
grossPerformancePercentage,
|
||||||
investment: new Big(position.investment).toNumber(),
|
netPerformance,
|
||||||
marketState:
|
netPerformancePercentage,
|
||||||
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
|
quantity,
|
||||||
name: symbolProfileMap[position.symbol].name,
|
symbol,
|
||||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
transactionCount
|
||||||
netPerformancePercentage:
|
}) => {
|
||||||
position.netPerformancePercentage?.toNumber() ?? null,
|
return {
|
||||||
quantity: new Big(position.quantity).toNumber()
|
currency,
|
||||||
};
|
dataSource,
|
||||||
})
|
firstBuyDate,
|
||||||
|
symbol,
|
||||||
|
transactionCount,
|
||||||
|
assetClass: symbolProfileMap[symbol].assetClass,
|
||||||
|
assetSubClass: symbolProfileMap[symbol].assetSubClass,
|
||||||
|
averagePrice: averagePrice.toNumber(),
|
||||||
|
grossPerformance: grossPerformance?.toNumber() ?? null,
|
||||||
|
grossPerformancePercentage:
|
||||||
|
grossPerformancePercentage?.toNumber() ?? null,
|
||||||
|
investment: investment.toNumber(),
|
||||||
|
marketState:
|
||||||
|
dataProviderResponses[symbol]?.marketState ?? 'delayed',
|
||||||
|
name: symbolProfileMap[symbol].name,
|
||||||
|
netPerformance: netPerformance?.toNumber() ?? null,
|
||||||
|
netPerformancePercentage:
|
||||||
|
netPerformancePercentage?.toNumber() ?? null,
|
||||||
|
quantity: quantity.toNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
@ -37,7 +38,7 @@ export class SubscriptionController {
|
|||||||
|
|
||||||
@Post('redeem-coupon')
|
@Post('redeem-coupon')
|
||||||
@HttpCode(StatusCodes.OK)
|
@HttpCode(StatusCodes.OK)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||||
if (!this.request.user) {
|
if (!this.request.user) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -109,7 +110,7 @@ export class SubscriptionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('stripe/checkout-session')
|
@Post('stripe/checkout-session')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createCheckoutSession(
|
public async createCheckoutSession(
|
||||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||||
) {
|
) {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
@ -34,7 +35,7 @@ export class SymbolController {
|
|||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
*/
|
*/
|
||||||
@Get('lookup')
|
@Get('lookup')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query('includeIndices') includeIndices: boolean = false,
|
@Query('includeIndices') includeIndices: boolean = false,
|
||||||
@ -88,7 +89,7 @@ export class SymbolController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:dateString')
|
@Get(':dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Tag } from '@prisma/client';
|
import { Tag } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -23,40 +22,25 @@ import { UpdateTagDto } from './update-tag.dto';
|
|||||||
|
|
||||||
@Controller('tag')
|
@Controller('tag')
|
||||||
export class TagController {
|
export class TagController {
|
||||||
public constructor(
|
public constructor(private readonly tagService: TagService) {}
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
|
||||||
private readonly tagService: TagService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getTags() {
|
public async getTags() {
|
||||||
return this.tagService.getTagsWithActivityCount();
|
return this.tagService.getTagsWithActivityCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.createTag)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||||
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.tagService.createTag(data);
|
return this.tagService.createTag(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateTag)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
||||||
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalTag = await this.tagService.getTag({
|
const originalTag = await this.tagService.getTag({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
@ -79,15 +63,9 @@ export class TagController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteTag)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteTag(@Param('id') id: string) {
|
public async deleteTag(@Param('id') id: string) {
|
||||||
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalTag = await this.tagService.getTag({
|
const originalTag = await this.tagService.getTag({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
@ -4,6 +4,7 @@ import type {
|
|||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsIn,
|
IsIn,
|
||||||
@ -37,6 +38,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
'filters.tags'?: string[];
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExperimentalFeatures?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -36,12 +38,10 @@ export class UserController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteUser)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||||
if (
|
if (id === this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
|
|
||||||
id === this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -54,7 +54,7 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getUser(
|
public async getUser(
|
||||||
@Headers('accept-language') acceptLanguage: string
|
@Headers('accept-language') acceptLanguage: string
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@ -92,7 +92,7 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('setting')
|
@Put('setting')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||||
if (
|
if (
|
||||||
size(data) === 1 &&
|
size(data) === 1 &&
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
|||||||
import { HttpException } from '@nestjs/common';
|
import { HttpException } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
|
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { HasPermissionGuard } from './has-permission.guard';
|
import { HasPermissionGuard } from './has-permission.guard';
|
||||||
|
|
||||||
@ -10,12 +9,8 @@ describe('HasPermissionGuard', () => {
|
|||||||
let reflector: Reflector;
|
let reflector: Reflector;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
reflector = new Reflector();
|
||||||
providers: [HasPermissionGuard, Reflector]
|
guard = new HasPermissionGuard(reflector);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
|
|
||||||
reflector = module.get<Reflector>(Reflector);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupReflectorSpy(returnValue: string) {
|
function setupReflectorSpy(returnValue: string) {
|
||||||
|
@ -14,17 +14,17 @@ export class HasPermissionGuard implements CanActivate {
|
|||||||
public constructor(private reflector: Reflector) {}
|
public constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
public canActivate(context: ExecutionContext): boolean {
|
public canActivate(context: ExecutionContext): boolean {
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
const requiredPermission = this.reflector.get<string>(
|
const requiredPermission = this.reflector.get<string>(
|
||||||
HAS_PERMISSION_KEY,
|
HAS_PERMISSION_KEY,
|
||||||
context.getHandler()
|
context.getHandler()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!requiredPermission) {
|
if (!requiredPermission) {
|
||||||
return true; // No specific permissions required
|
// No specific permissions required
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
if (!user || !hasPermission(user.permissions, requiredPermission)) {
|
if (!user || !hasPermission(user.permissions, requiredPermission)) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
@ -32,9 +32,11 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function redactAttributes({
|
export function redactAttributes({
|
||||||
|
isFirstRun = true,
|
||||||
object,
|
object,
|
||||||
options
|
options
|
||||||
}: {
|
}: {
|
||||||
|
isFirstRun?: boolean;
|
||||||
object: any;
|
object: any;
|
||||||
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||||
}): any {
|
}): any {
|
||||||
@ -42,7 +44,10 @@ export function redactAttributes({
|
|||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redactedObject = cloneDeep(object);
|
// Create deep clone
|
||||||
|
const redactedObject = isFirstRun
|
||||||
|
? JSON.parse(JSON.stringify(object))
|
||||||
|
: object;
|
||||||
|
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
if (redactedObject.hasOwnProperty(option.attribute)) {
|
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||||
@ -59,7 +64,11 @@ export function redactAttributes({
|
|||||||
if (isArray(redactedObject[property])) {
|
if (isArray(redactedObject[property])) {
|
||||||
redactedObject[property] = redactedObject[property].map(
|
redactedObject[property] = redactedObject[property].map(
|
||||||
(currentObject) => {
|
(currentObject) => {
|
||||||
return redactAttributes({ options, object: currentObject });
|
return redactAttributes({
|
||||||
|
options,
|
||||||
|
isFirstRun: false,
|
||||||
|
object: currentObject
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
@ -69,6 +78,7 @@ export function redactAttributes({
|
|||||||
// Recursively call the function on the nested object
|
// Recursively call the function on the nested object
|
||||||
redactedObject[property] = redactAttributes({
|
redactedObject[property] = redactAttributes({
|
||||||
options,
|
options,
|
||||||
|
isFirstRun: false,
|
||||||
object: redactedObject[property]
|
object: redactedObject[property]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,8 @@ 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: '' }),
|
||||||
|
API_KEY_COINGECKO_DEMO: str({ default: '' }),
|
||||||
|
API_KEY_COINGECKO_PRO: str({ default: '' }),
|
||||||
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
||||||
CACHE_QUOTES_TTL: num({ default: 1 }),
|
CACHE_QUOTES_TTL: num({ default: 1 }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
@ -44,6 +46,7 @@ export class ConfigurationService {
|
|||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
REDIS_PASSWORD: str({ default: '' }),
|
REDIS_PASSWORD: str({ default: '' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
|
REQUEST_TIMEOUT: num({ default: 2000 }),
|
||||||
ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
|
ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
|
||||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||||
|
@ -5,7 +5,6 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -107,7 +106,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DEFAULT_REQUEST_TIMEOUT
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
@ -19,13 +17,30 @@ import {
|
|||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||||
import got from 'got';
|
import got, { Headers } from 'got';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CoinGeckoService implements DataProviderInterface {
|
export class CoinGeckoService implements DataProviderInterface {
|
||||||
private readonly URL = 'https://api.coingecko.com/api/v3';
|
private readonly apiUrl: string;
|
||||||
|
private readonly headers: Headers = {};
|
||||||
|
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
|
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
|
||||||
|
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO');
|
||||||
|
|
||||||
|
this.apiUrl = 'https://api.coingecko.com/api/v3';
|
||||||
|
|
||||||
|
if (apiKeyDemo) {
|
||||||
|
this.headers['x-cg-demo-api-key'] = apiKeyDemo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKeyPro) {
|
||||||
|
this.apiUrl = 'https://pro-api.coingecko.com/api/v3';
|
||||||
|
this.headers['x-cg-pro-api-key'] = apiKeyPro;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return true;
|
return true;
|
||||||
@ -47,9 +62,10 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
|
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, {
|
||||||
|
headers: this.headers,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
}).json<any>();
|
}).json<any>();
|
||||||
@ -59,7 +75,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'CoinGeckoService');
|
Logger.error(message, 'CoinGeckoService');
|
||||||
@ -95,15 +113,16 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { prices } = await got(
|
const { prices } = await got(
|
||||||
`${
|
`${
|
||||||
this.URL
|
this.apiUrl
|
||||||
}/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
|
}/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
|
||||||
from
|
from
|
||||||
)}&to=${getUnixTime(to)}`,
|
)}&to=${getUnixTime(to)}`,
|
||||||
{
|
{
|
||||||
|
headers: this.headers,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
@ -141,7 +160,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
@ -161,10 +180,11 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
}, requestTimeout);
|
}, requestTimeout);
|
||||||
|
|
||||||
const quotes = await got(
|
const quotes = await got(
|
||||||
`${this.URL}/simple/price?ids=${symbols.join(
|
`${this.apiUrl}/simple/price?ids=${symbols.join(
|
||||||
','
|
','
|
||||||
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
||||||
{
|
{
|
||||||
|
headers: this.headers,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
@ -183,7 +203,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'CoinGeckoService');
|
Logger.error(message, 'CoinGeckoService');
|
||||||
@ -210,9 +232,10 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { coins } = await got(`${this.URL}/search?query=${query}`, {
|
const { coins } = await got(`${this.apiUrl}/search?query=${query}`, {
|
||||||
|
headers: this.headers,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
}).json<any>();
|
}).json<any>();
|
||||||
@ -231,7 +254,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'CoinGeckoService');
|
Logger.error(message, 'CoinGeckoService');
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
import { parseSymbol } from '@ghostfolio/common/helper';
|
import { parseSymbol } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
@ -15,7 +14,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async enhance({
|
public async enhance({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
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';
|
||||||
@ -21,8 +21,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
'Information Technology': 'Technology'
|
'Information Technology': 'Technology'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async enhance({
|
public async enhance({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
@ -55,7 +59,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
return got(
|
return got(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
|
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
|
||||||
@ -82,7 +86,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const holdings = await got(
|
const holdings = await got(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
|
||||||
@ -97,7 +101,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
return got(
|
return got(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
|
||||||
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
|
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
|
||||||
@ -25,13 +26,16 @@ jest.mock(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('YahooFinanceDataEnhancerService', () => {
|
describe('YahooFinanceDataEnhancerService', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let cryptocurrencyService: CryptocurrencyService;
|
let cryptocurrencyService: CryptocurrencyService;
|
||||||
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
|
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
cryptocurrencyService = new CryptocurrencyService();
|
cryptocurrencyService = new CryptocurrencyService();
|
||||||
|
|
||||||
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
|
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
|
||||||
|
configurationService,
|
||||||
cryptocurrencyService
|
cryptocurrencyService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
import {
|
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DEFAULT_REQUEST_TIMEOUT,
|
|
||||||
UNKNOWN_KEY
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { isCurrency } from '@ghostfolio/common/helper';
|
import { isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@ -22,6 +19,7 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly cryptocurrencyService: CryptocurrencyService
|
private readonly cryptocurrencyService: CryptocurrencyService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -76,7 +74,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async enhance({
|
public async enhance({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
@ -74,6 +74,6 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
},
|
},
|
||||||
YahooFinanceDataEnhancerService
|
YahooFinanceDataEnhancerService
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, YahooFinanceService]
|
exports: [DataProviderService, ManualService, YahooFinanceService]
|
||||||
})
|
})
|
||||||
export class DataProviderModule {}
|
export class DataProviderModule {}
|
||||||
|
@ -5,10 +5,7 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DEFAULT_REQUEST_TIMEOUT
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -82,7 +79,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const response = await got(
|
const response = await got(
|
||||||
`${this.URL}/eod/${symbol}?api_token=${
|
`${this.URL}/eod/${symbol}?api_token=${
|
||||||
@ -132,7 +129,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
@ -194,7 +191,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
})?.currency;
|
})?.currency;
|
||||||
|
|
||||||
result[this.convertFromEodSymbol(code)] = {
|
result[this.convertFromEodSymbol(code)] = {
|
||||||
currency: currency ?? DEFAULT_CURRENCY,
|
currency:
|
||||||
|
currency ??
|
||||||
|
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
|
||||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||||
marketPrice: close,
|
marketPrice: close,
|
||||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||||
@ -208,7 +207,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
if (response[`${DEFAULT_CURRENCY}GBP`]) {
|
if (response[`${DEFAULT_CURRENCY}GBP`]) {
|
||||||
response[`${DEFAULT_CURRENCY}GBp`] = {
|
response[`${DEFAULT_CURRENCY}GBp`] = {
|
||||||
...response[`${DEFAULT_CURRENCY}GBP`],
|
...response[`${DEFAULT_CURRENCY}GBP`],
|
||||||
currency: `${DEFAULT_CURRENCY}GBp`,
|
currency: 'GBp',
|
||||||
marketPrice: this.getConvertedValue({
|
marketPrice: this.getConvertedValue({
|
||||||
symbol: `${DEFAULT_CURRENCY}GBp`,
|
symbol: `${DEFAULT_CURRENCY}GBp`,
|
||||||
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
|
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
|
||||||
@ -219,7 +218,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
if (response[`${DEFAULT_CURRENCY}ILS`]) {
|
if (response[`${DEFAULT_CURRENCY}ILS`]) {
|
||||||
response[`${DEFAULT_CURRENCY}ILA`] = {
|
response[`${DEFAULT_CURRENCY}ILA`] = {
|
||||||
...response[`${DEFAULT_CURRENCY}ILS`],
|
...response[`${DEFAULT_CURRENCY}ILS`],
|
||||||
currency: `${DEFAULT_CURRENCY}ILA`,
|
currency: 'ILA',
|
||||||
marketPrice: this.getConvertedValue({
|
marketPrice: this.getConvertedValue({
|
||||||
symbol: `${DEFAULT_CURRENCY}ILA`,
|
symbol: `${DEFAULT_CURRENCY}ILA`,
|
||||||
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
|
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
|
||||||
@ -227,12 +226,23 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response[`${DEFAULT_CURRENCY}USX`]) {
|
||||||
|
response[`${DEFAULT_CURRENCY}USX`] = {
|
||||||
|
currency: 'USX',
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice: new Big(1).mul(100).toNumber(),
|
||||||
|
marketState: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'EodHistoricalDataService');
|
Logger.error(message, 'EodHistoricalDataService');
|
||||||
@ -359,7 +369,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const response = await got(
|
const response = await got(
|
||||||
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||||
@ -391,7 +401,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'EodHistoricalDataService');
|
Logger.error(message, 'EodHistoricalDataService');
|
||||||
|
@ -5,10 +5,7 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DEFAULT_REQUEST_TIMEOUT
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
@ -70,7 +67,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { historical } = await got(
|
const { historical } = await got(
|
||||||
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
|
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
|
||||||
@ -114,7 +111,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
@ -154,7 +151,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'FinancialModelingPrepService');
|
Logger.error(message, 'FinancialModelingPrepService');
|
||||||
@ -181,7 +180,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const result = await got(
|
const result = await got(
|
||||||
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
||||||
@ -205,7 +204,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'FinancialModelingPrepService');
|
Logger.error(message, 'FinancialModelingPrepService');
|
||||||
|
@ -7,7 +7,6 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -101,7 +100,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
@ -6,23 +7,25 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
extractNumberFromString,
|
extractNumberFromString,
|
||||||
getYesterday
|
getYesterday
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { ScraperConfiguration } from '@ghostfolio/common/interfaces';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { isUUID } from 'class-validator';
|
import { isUUID } from 'class-validator';
|
||||||
import { addDays, format, isBefore } from 'date-fns';
|
import { addDays, format, isBefore } from 'date-fns';
|
||||||
import got from 'got';
|
import got, { Headers } from 'got';
|
||||||
|
import jsonpath from 'jsonpath';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManualService implements DataProviderInterface {
|
export class ManualService implements DataProviderInterface {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -96,21 +99,7 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const value = await this.scrape(symbolProfile.scraperConfiguration);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
abortController.abort();
|
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
|
||||||
|
|
||||||
const { body } = await got(url, {
|
|
||||||
headers,
|
|
||||||
// @ts-ignore
|
|
||||||
signal: abortController.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
const $ = cheerio.load(body);
|
|
||||||
|
|
||||||
const value = extractNumberFromString($(selector).text());
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
@ -134,7 +123,7 @@ export class ManualService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
@ -232,4 +221,43 @@ export class ManualService implements DataProviderInterface {
|
|||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async test(scraperConfiguration: ScraperConfiguration) {
|
||||||
|
return this.scrape(scraperConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scrape(
|
||||||
|
scraperConfiguration: ScraperConfiguration
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
|
const { body, headers } = await got(scraperConfiguration.url, {
|
||||||
|
headers: scraperConfiguration.headers as Headers,
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (headers['content-type'] === 'application/json') {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const value = String(
|
||||||
|
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
return extractNumberFromString(value);
|
||||||
|
} else {
|
||||||
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
|
return extractNumberFromString(
|
||||||
|
$(scraperConfiguration.selector).first().text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,7 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
DEFAULT_REQUEST_TIMEOUT,
|
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -88,7 +85,7 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
@ -146,7 +143,7 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { fgi } = await got(
|
const { fgi } = await got(
|
||||||
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
|
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
|
||||||
@ -166,7 +163,9 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
|
'REQUEST_TIMEOUT'
|
||||||
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.error(message, 'RapidApiService');
|
Logger.error(message, 'RapidApiService');
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
@ -6,10 +7,7 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DEFAULT_REQUEST_TIMEOUT
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -22,6 +20,7 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class YahooFinanceService implements DataProviderInterface {
|
export class YahooFinanceService implements DataProviderInterface {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly cryptocurrencyService: CryptocurrencyService,
|
private readonly cryptocurrencyService: CryptocurrencyService,
|
||||||
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
|
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
|
||||||
) {}
|
) {}
|
||||||
@ -160,7 +159,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
|
@ -11,6 +11,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format, isToday } from 'date-fns';
|
import { format, isToday } from 'date-fns';
|
||||||
import { isNumber, uniq } from 'lodash';
|
import { isNumber, uniq } from 'lodash';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExchangeRateDataService {
|
export class ExchangeRateDataService {
|
||||||
@ -33,6 +34,125 @@ export class ExchangeRateDataService {
|
|||||||
return this.currencyPairs;
|
return this.currencyPairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getExchangeRates({
|
||||||
|
currencyFrom,
|
||||||
|
currencyTo,
|
||||||
|
dates
|
||||||
|
}: {
|
||||||
|
currencyFrom: string;
|
||||||
|
currencyTo: string;
|
||||||
|
dates: Date[];
|
||||||
|
}) {
|
||||||
|
let factors: { [dateString: string]: number } = {};
|
||||||
|
|
||||||
|
if (currencyFrom === currencyTo) {
|
||||||
|
for (const date of dates) {
|
||||||
|
factors[format(date, DATE_FORMAT)] = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const dataSource =
|
||||||
|
this.dataProviderService.getDataSourceForExchangeRates();
|
||||||
|
const symbol = `${currencyFrom}${currencyTo}`;
|
||||||
|
|
||||||
|
const marketData = await this.marketDataService.getRange({
|
||||||
|
dateQuery: { in: dates },
|
||||||
|
uniqueAssets: [
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (marketData?.length > 0) {
|
||||||
|
for (const { date, marketPrice } of marketData) {
|
||||||
|
factors[format(date, DATE_FORMAT)] = marketPrice;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Calculate indirectly via base currency
|
||||||
|
|
||||||
|
let marketPriceBaseCurrencyFromCurrency: {
|
||||||
|
[dateString: string]: number;
|
||||||
|
} = {};
|
||||||
|
let marketPriceBaseCurrencyToCurrency: {
|
||||||
|
[dateString: string]: number;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currencyFrom === DEFAULT_CURRENCY) {
|
||||||
|
for (const date of dates) {
|
||||||
|
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
|
||||||
|
1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const marketData = await this.marketDataService.getRange({
|
||||||
|
dateQuery: { in: dates },
|
||||||
|
uniqueAssets: [
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { date, marketPrice } of marketData) {
|
||||||
|
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
|
||||||
|
marketPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currencyTo === DEFAULT_CURRENCY) {
|
||||||
|
for (const date of dates) {
|
||||||
|
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const marketData = await this.marketDataService.getRange({
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
uniqueAssets: [
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { date, marketPrice } of marketData) {
|
||||||
|
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] =
|
||||||
|
marketPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
try {
|
||||||
|
const factor =
|
||||||
|
(1 /
|
||||||
|
marketPriceBaseCurrencyFromCurrency[
|
||||||
|
format(date, DATE_FORMAT)
|
||||||
|
]) *
|
||||||
|
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
|
||||||
|
|
||||||
|
factors[format(date, DATE_FORMAT)] = factor;
|
||||||
|
} catch {
|
||||||
|
Logger.error(
|
||||||
|
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||||
|
date,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
'ExchangeRateDataService'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return factors;
|
||||||
|
}
|
||||||
|
|
||||||
public hasCurrencyPair(currency1: string, currency2: string) {
|
public hasCurrencyPair(currency1: string, currency2: string) {
|
||||||
return this.currencyPairs.some(({ symbol }) => {
|
return this.currencyPairs.some(({ symbol }) => {
|
||||||
return (
|
return (
|
||||||
@ -75,7 +195,8 @@ export class ExchangeRateDataService {
|
|||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: this.currencyPairs.map(({ dataSource, symbol }) => {
|
items: this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
}),
|
||||||
|
requestTimeout: ms('30 seconds')
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const symbol of Object.keys(quotes)) {
|
for (const symbol of Object.keys(quotes)) {
|
||||||
|
@ -3,6 +3,8 @@ import { CleanedEnvAccessors } from 'envalid';
|
|||||||
export interface Environment extends CleanedEnvAccessors {
|
export interface Environment extends CleanedEnvAccessors {
|
||||||
ACCESS_TOKEN_SALT: string;
|
ACCESS_TOKEN_SALT: string;
|
||||||
ALPHA_VANTAGE_API_KEY: string;
|
ALPHA_VANTAGE_API_KEY: string;
|
||||||
|
API_KEY_COINGECKO_DEMO: string;
|
||||||
|
API_KEY_COINGECKO_PRO: string;
|
||||||
BETTER_UPTIME_API_KEY: string;
|
BETTER_UPTIME_API_KEY: string;
|
||||||
CACHE_QUOTES_TTL: number;
|
CACHE_QUOTES_TTL: number;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
@ -32,6 +34,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
REDIS_PASSWORD: string;
|
REDIS_PASSWORD: string;
|
||||||
REDIS_PORT: number;
|
REDIS_PORT: number;
|
||||||
|
REQUEST_TIMEOUT: number;
|
||||||
ROOT_URL: string;
|
ROOT_URL: string;
|
||||||
STRIPE_PUBLIC_KEY: string;
|
STRIPE_PUBLIC_KEY: string;
|
||||||
STRIPE_SECRET_KEY: string;
|
STRIPE_SECRET_KEY: string;
|
||||||
|
@ -89,6 +89,7 @@ export class SymbolProfileService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
@ -100,6 +101,7 @@ export class SymbolProfileService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
|
6
apps/api/webpack.config.js
Normal file
6
apps/api/webpack.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const { composePlugins, withNx } = require('@nx/webpack');
|
||||||
|
|
||||||
|
module.exports = composePlugins(withNx(), (config, { options, context }) => {
|
||||||
|
// Customize webpack config here
|
||||||
|
return config;
|
||||||
|
});
|
@ -150,41 +150,41 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/angular:webpack-dev-server",
|
"executor": "@nx/angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"proxyConfig": "apps/client/proxy.conf.json",
|
"proxyConfig": "apps/client/proxy.conf.json",
|
||||||
"browserTarget": "client:build"
|
"buildTarget": "client:build"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"development-de": {
|
"development-de": {
|
||||||
"browserTarget": "client:build:development-de"
|
"buildTarget": "client:build:development-de"
|
||||||
},
|
},
|
||||||
"development-en": {
|
"development-en": {
|
||||||
"browserTarget": "client:build:development-en"
|
"buildTarget": "client:build:development-en"
|
||||||
},
|
},
|
||||||
"development-es": {
|
"development-es": {
|
||||||
"browserTarget": "client:build:development-es"
|
"buildTarget": "client:build:development-es"
|
||||||
},
|
},
|
||||||
"development-fr": {
|
"development-fr": {
|
||||||
"browserTarget": "client:build:development-fr"
|
"buildTarget": "client:build:development-fr"
|
||||||
},
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"browserTarget": "client:build:development-it"
|
"buildTarget": "client:build:development-it"
|
||||||
},
|
},
|
||||||
"development-nl": {
|
"development-nl": {
|
||||||
"browserTarget": "client:build:development-nl"
|
"buildTarget": "client:build:development-nl"
|
||||||
},
|
},
|
||||||
"development-pl": {
|
"development-pl": {
|
||||||
"browserTarget": "client:build:development-pl"
|
"buildTarget": "client:build:development-pl"
|
||||||
},
|
},
|
||||||
"development-pt": {
|
"development-pt": {
|
||||||
"browserTarget": "client:build:development-pt"
|
"buildTarget": "client:build:development-pt"
|
||||||
},
|
},
|
||||||
"development-tr": {
|
"development-tr": {
|
||||||
"browserTarget": "client:build:development-tr"
|
"buildTarget": "client:build:development-tr"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"buildTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -207,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nrwl/linter:eslint",
|
"executor": "@nx/eslint:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -96,8 +96,8 @@
|
|||||||
href="https://status.ghostfol.io"
|
href="https://status.ghostfol.io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Ghostfolio Status"
|
title="Ghostfolio Status"
|
||||||
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
|
>Status<ion-icon class="ml-1" name="open-outline"
|
||||||
></a>
|
/></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -110,8 +110,8 @@
|
|||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Find Ghostfolio on GitHub"
|
title="Find Ghostfolio on GitHub"
|
||||||
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
|
>GitHub<ion-icon class="ml-1" name="open-outline"
|
||||||
></a>
|
/></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@ -119,8 +119,8 @@
|
|||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Join the Ghostfolio Slack community"
|
title="Join the Ghostfolio Slack community"
|
||||||
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
|
>Slack<ion-icon class="ml-1" name="open-outline"
|
||||||
></a>
|
/></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@ -128,11 +128,8 @@
|
|||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||||
>X (formerly Twitter)<ion-icon
|
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
|
||||||
class="ml-1"
|
/></a>
|
||||||
name="open-outline"
|
|
||||||
></ion-icon
|
|
||||||
></a>
|
|
||||||
</li>
|
</li>
|
||||||
<li> </li>
|
<li> </li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 100vh;
|
min-height: 100svh;
|
||||||
|
|
||||||
&.has-info-message {
|
&.has-info-message {
|
||||||
header {
|
header {
|
||||||
@ -30,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height));
|
min-height: calc(100svh - 2 * var(--mat-toolbar-standard-height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
min-height: calc(100vh - var(--mat-toolbar-standard-height));
|
min-height: calc(100svh - var(--mat-toolbar-standard-height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,26 +14,26 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
<ng-container>
|
<div class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
<ion-icon class="mr-1" name="lock-closed-outline" />
|
||||||
Restricted View
|
<ng-container i18n>Restricted View</ng-container>
|
||||||
</ng-container>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="details">
|
<ng-container matColumnDef="details">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
<div *ngIf="element.type === 'PUBLIC'" class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
<ion-icon class="mr-1" name="link-outline" />
|
||||||
<a
|
<a
|
||||||
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
||||||
>
|
>
|
||||||
</ng-container>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -47,7 +47,7 @@
|
|||||||
[matMenuTriggerFor]="transactionMenu"
|
[matMenuTriggerFor]="transactionMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||||
|
@ -7,6 +7,8 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Sort, SortDirection } from '@angular/material/sort';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -16,6 +18,7 @@ import {
|
|||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
User
|
User
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
@ -24,7 +27,6 @@ import { Subject } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AccountDetailDialogParams } from './interfaces/interfaces';
|
import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'd-flex flex-column h-100' },
|
host: { class: 'd-flex flex-column h-100' },
|
||||||
@ -38,6 +40,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
public activities: OrderWithAccount[];
|
public activities: OrderWithAccount[];
|
||||||
public balance: number;
|
public balance: number;
|
||||||
public currency: string;
|
public currency: string;
|
||||||
|
public dataSource: MatTableDataSource<OrderWithAccount>;
|
||||||
public equity: number;
|
public equity: number;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToDeleteAccountBalance: boolean;
|
public hasPermissionToDeleteAccountBalance: boolean;
|
||||||
@ -46,6 +49,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
public isLoadingChart: boolean;
|
public isLoadingChart: boolean;
|
||||||
public name: string;
|
public name: string;
|
||||||
public platformName: string;
|
public platformName: string;
|
||||||
|
public sortColumn = 'date';
|
||||||
|
public sortDirection: SortDirection = 'desc';
|
||||||
|
public totalItems: number;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
public valueInBaseCurrency: number;
|
public valueInBaseCurrency: number;
|
||||||
@ -77,8 +83,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.isLoadingActivities = true;
|
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchAccount(this.data.accountId)
|
.fetchAccount(this.data.accountId)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -110,19 +114,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.dataService
|
|
||||||
.fetchActivities({
|
|
||||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ activities }) => {
|
|
||||||
this.activities = activities;
|
|
||||||
|
|
||||||
this.isLoadingActivities = false;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -131,6 +122,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.fetchAccountBalances();
|
this.fetchAccountBalances();
|
||||||
|
this.fetchActivities();
|
||||||
this.fetchPortfolioPerformance();
|
this.fetchPortfolioPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,12 +143,20 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onExport() {
|
public onExport() {
|
||||||
|
let activityIds = [];
|
||||||
|
|
||||||
|
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||||
|
activityIds = this.dataSource.data.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
activityIds = this.activities.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchExport(
|
.fetchExport(activityIds)
|
||||||
this.activities.map(({ id }) => {
|
|
||||||
return id;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
downloadAsFile({
|
downloadAsFile({
|
||||||
@ -172,6 +172,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSortChanged({ active, direction }: Sort) {
|
||||||
|
this.sortColumn = active;
|
||||||
|
this.sortDirection = direction;
|
||||||
|
|
||||||
|
this.fetchActivities();
|
||||||
|
}
|
||||||
|
|
||||||
private fetchAccountBalances() {
|
private fetchAccountBalances() {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchAccountBalances(this.data.accountId)
|
.fetchAccountBalances(this.data.accountId)
|
||||||
@ -183,6 +190,41 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fetchActivities() {
|
||||||
|
this.isLoadingActivities = true;
|
||||||
|
|
||||||
|
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||||
|
this.dataService
|
||||||
|
.fetchActivities({
|
||||||
|
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
|
||||||
|
sortColumn: this.sortColumn,
|
||||||
|
sortDirection: this.sortDirection
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ activities, count }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(activities);
|
||||||
|
this.totalItems = count;
|
||||||
|
|
||||||
|
this.isLoadingActivities = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.dataService
|
||||||
|
.fetchActivities({
|
||||||
|
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ activities }) => {
|
||||||
|
this.activities = activities;
|
||||||
|
|
||||||
|
this.isLoadingActivities = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fetchPortfolioPerformance() {
|
private fetchPortfolioPerformance() {
|
||||||
this.isLoadingChart = true;
|
this.isLoadingChart = true;
|
||||||
|
|
||||||
|
@ -71,7 +71,25 @@
|
|||||||
>
|
>
|
||||||
<mat-tab>
|
<mat-tab>
|
||||||
<ng-template i18n mat-tab-label>Activities</ng-template>
|
<ng-template i18n mat-tab-label>Activities</ng-template>
|
||||||
|
<gf-activities-table-lazy
|
||||||
|
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[dataSource]="dataSource"
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
[hasPermissionToCreateActivity]="false"
|
||||||
|
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||||
|
[hasPermissionToFilter]="false"
|
||||||
|
[hasPermissionToOpenDetails]="false"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[showActions]="false"
|
||||||
|
[sortColumn]="sortColumn"
|
||||||
|
[sortDirection]="sortDirection"
|
||||||
|
[totalItems]="totalItems"
|
||||||
|
(export)="onExport()"
|
||||||
|
(sortChanged)="onSortChanged($event)"
|
||||||
|
></gf-activities-table-lazy>
|
||||||
<gf-activities-table
|
<gf-activities-table
|
||||||
|
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||||
[activities]="activities"
|
[activities]="activities"
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="data.deviceType"
|
[deviceType]="data.deviceType"
|
||||||
|
@ -7,6 +7,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
|
|||||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||||
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
|
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
|
||||||
|
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
@ -19,6 +20,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
GfAccountBalancesModule,
|
GfAccountBalancesModule,
|
||||||
GfActivitiesTableModule,
|
GfActivitiesTableModule,
|
||||||
|
GfActivitiesTableLazyModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
GfInvestmentChartModule,
|
GfInvestmentChartModule,
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
[disabled]="dataSource?.data.length < 2"
|
[disabled]="dataSource?.data.length < 2"
|
||||||
(click)="onTransferBalance()"
|
(click)="onTransferBalance()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
|
<ion-icon class="mr-2" name="arrow-redo-outline" />
|
||||||
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -19,7 +19,7 @@
|
|||||||
></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>
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline"></ion-icon>
|
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
@ -231,7 +231,7 @@
|
|||||||
title="Note"
|
title="Note"
|
||||||
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="document-text-outline"></ion-icon>
|
<ion-icon name="document-text-outline" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
@ -250,12 +250,12 @@
|
|||||||
[matMenuTriggerFor]="accountMenu"
|
[matMenuTriggerFor]="accountMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
<span i18n>Edit</span>
|
<span i18n>Edit</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -265,7 +265,7 @@
|
|||||||
(click)="onDeleteAccount(element.id)"
|
(click)="onDeleteAccount(element.id)"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
<span i18n>Delete</span>
|
<span i18n>Delete</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -90,33 +90,24 @@
|
|||||||
<ng-container i18n>Status</ng-container>
|
<ng-container i18n>Status</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
<ion-icon
|
<ion-icon *ngIf="element.state === 'active'" name="play-outline" />
|
||||||
*ngIf="element.state === 'active'"
|
|
||||||
name="play-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="element.state === 'completed'"
|
*ngIf="element.state === 'completed'"
|
||||||
class="text-success"
|
class="text-success"
|
||||||
name="checkmark-circle-outline"
|
name="checkmark-circle-outline"
|
||||||
></ion-icon>
|
/>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="element.state === 'delayed'"
|
*ngIf="element.state === 'delayed'"
|
||||||
name="time-outline"
|
name="time-outline"
|
||||||
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
||||||
></ion-icon>
|
/>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="element.state === 'failed'"
|
*ngIf="element.state === 'failed'"
|
||||||
class="text-danger"
|
class="text-danger"
|
||||||
name="alert-circle-outline"
|
name="alert-circle-outline"
|
||||||
></ion-icon>
|
/>
|
||||||
<ion-icon
|
<ion-icon *ngIf="element.state === 'paused'" name="pause-outline" />
|
||||||
*ngIf="element.state === 'paused'"
|
<ion-icon *ngIf="element.state === 'waiting'" name="cafe-outline" />
|
||||||
name="pause-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="element.state === 'waiting'"
|
|
||||||
name="cafe-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -128,7 +119,7 @@
|
|||||||
[matMenuTriggerFor]="jobsActionsMenu"
|
[matMenuTriggerFor]="jobsActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onDeleteJobs()">
|
<button mat-menu-item (click)="onDeleteJobs()">
|
||||||
@ -143,7 +134,7 @@
|
|||||||
[matMenuTriggerFor]="jobActionsMenu"
|
[matMenuTriggerFor]="jobActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onViewData(element.data)">
|
<button mat-menu-item (click)="onViewData(element.data)">
|
||||||
|
@ -4,6 +4,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
gf-line-chart {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
class="text-muted"
|
class="text-muted"
|
||||||
matDatepickerToggleIcon
|
matDatepickerToggleIcon
|
||||||
name="calendar-clear-outline"
|
name="calendar-clear-outline"
|
||||||
></ion-icon>
|
/>
|
||||||
</mat-datepicker-toggle>
|
</mat-datepicker-toggle>
|
||||||
<mat-datepicker #date disabled="true"></mat-datepicker>
|
<mat-datepicker #date disabled="true"></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
title="Fetch market price"
|
title="Fetch market price"
|
||||||
(click)="onFetchSymbolForDate()"
|
(click)="onFetchSymbolForDate()"
|
||||||
>
|
>
|
||||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
<ion-icon class="text-muted" name="refresh-outline" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -125,7 +125,7 @@
|
|||||||
*ngIf="element.comment"
|
*ngIf="element.comment"
|
||||||
class="d-block"
|
class="d-block"
|
||||||
name="document-text-outline"
|
name="document-text-outline"
|
||||||
></ion-icon>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -137,7 +137,7 @@
|
|||||||
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onGather7Days()">
|
<button mat-menu-item (click)="onGather7Days()">
|
||||||
@ -158,7 +158,7 @@
|
|||||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
@ -166,7 +166,7 @@
|
|||||||
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
|
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
<span i18n>Edit</span>
|
<span i18n>Edit</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -176,7 +176,7 @@
|
|||||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
<span i18n>Delete</span>
|
<span i18n>Delete</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -225,7 +225,7 @@
|
|||||||
[queryParams]="{ createAssetProfileDialog: true }"
|
[queryParams]="{ createAssetProfileDialog: true }"
|
||||||
[routerLink]="[]"
|
[routerLink]="[]"
|
||||||
>
|
>
|
||||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
<ion-icon name="add-outline" size="large" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -15,6 +15,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
|||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
Currency,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
@ -51,6 +52,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
assetClass: new FormControl<AssetClass>(undefined),
|
assetClass: new FormControl<AssetClass>(undefined),
|
||||||
assetSubClass: new FormControl<AssetSubClass>(undefined),
|
assetSubClass: new FormControl<AssetSubClass>(undefined),
|
||||||
comment: '',
|
comment: '',
|
||||||
|
currency: '',
|
||||||
historicalData: this.formBuilder.group({
|
historicalData: this.formBuilder.group({
|
||||||
csvString: ''
|
csvString: ''
|
||||||
}),
|
}),
|
||||||
@ -63,6 +65,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public currencies: Currency[] = [];
|
||||||
public isBenchmark = false;
|
public isBenchmark = false;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataDetails: MarketData[] = [];
|
||||||
public sectors: {
|
public sectors: {
|
||||||
@ -86,7 +89,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
this.benchmarks = this.dataService.fetchInfo().benchmarks;
|
const { benchmarks, currencies } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.benchmarks = benchmarks;
|
||||||
|
this.currencies = currencies.map((currency) => ({
|
||||||
|
label: currency,
|
||||||
|
value: currency
|
||||||
|
}));
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
@ -132,6 +141,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
assetClass: this.assetProfile.assetClass ?? null,
|
assetClass: this.assetProfile.assetClass ?? null,
|
||||||
assetSubClass: this.assetProfile.assetSubClass ?? null,
|
assetSubClass: this.assetProfile.assetSubClass ?? null,
|
||||||
comment: this.assetProfile?.comment ?? '',
|
comment: this.assetProfile?.comment ?? '',
|
||||||
|
currency: this.assetProfile?.currency,
|
||||||
historicalData: {
|
historicalData: {
|
||||||
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
|
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
|
||||||
},
|
},
|
||||||
@ -245,12 +255,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const assetProfileData: UpdateAssetProfileDto = {
|
const assetProfileData: UpdateAssetProfileDto = {
|
||||||
|
scraperConfiguration,
|
||||||
|
symbolMapping,
|
||||||
assetClass: this.assetProfileForm.controls['assetClass'].value,
|
assetClass: this.assetProfileForm.controls['assetClass'].value,
|
||||||
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
|
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
|
||||||
comment: this.assetProfileForm.controls['comment'].value ?? null,
|
comment: this.assetProfileForm.controls['comment'].value ?? null,
|
||||||
name: this.assetProfileForm.controls['name'].value,
|
currency: (<Currency>(
|
||||||
scraperConfiguration,
|
(<unknown>this.assetProfileForm.controls['currency'].value)
|
||||||
symbolMapping
|
))?.value,
|
||||||
|
name: this.assetProfileForm.controls['name'].value
|
||||||
};
|
};
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
@ -264,6 +277,34 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onTestMarketData() {
|
||||||
|
this.adminService
|
||||||
|
.testMarketData({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
scraperConfiguration:
|
||||||
|
this.assetProfileForm.controls['scraperConfiguration'].value,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
catchError(({ error }) => {
|
||||||
|
alert(`Error: ${error?.message}`);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ price }) => {
|
||||||
|
alert(
|
||||||
|
$localize`The current market price is` +
|
||||||
|
' ' +
|
||||||
|
price +
|
||||||
|
' ' +
|
||||||
|
(<Currency>(
|
||||||
|
(<unknown>this.assetProfileForm.controls['currency'].value)
|
||||||
|
))?.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.deleteBenchmark({ dataSource, symbol })
|
.deleteBenchmark({ dataSource, symbol })
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item type="button" (click)="initialize()">
|
<button mat-menu-item type="button" (click)="initialize()">
|
||||||
@ -183,6 +183,15 @@
|
|||||||
<input formControlName="name" matInput type="text" />
|
<input formControlName="name" matInput type="text" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label i18n>Currency</mat-label>
|
||||||
|
<gf-currency-selector
|
||||||
|
formControlName="currency"
|
||||||
|
[currencies]="currencies"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
<mat-label i18n>Asset Class</mat-label>
|
<mat-label i18n>Asset Class</mat-label>
|
||||||
@ -234,12 +243,24 @@
|
|||||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Scraper Configuration</mat-label>
|
<mat-label i18n>Scraper Configuration</mat-label>
|
||||||
<textarea
|
<div class="align-items-end d-flex">
|
||||||
cdkTextareaAutosize
|
<textarea
|
||||||
formControlName="scraperConfiguration"
|
cdkTextareaAutosize
|
||||||
matInput
|
formControlName="scraperConfiguration"
|
||||||
type="text"
|
matInput
|
||||||
></textarea>
|
type="text"
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'"
|
||||||
|
(click)="onTestMarketData()"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Test</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -10,6 +10,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
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 { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
|
||||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
GfAdminMarketDataDetailModule,
|
GfAdminMarketDataDetailModule,
|
||||||
|
GfCurrencySelectorModule,
|
||||||
GfPortfolioProportionChartModule,
|
GfPortfolioProportionChartModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -119,8 +119,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
const currency = prompt($localize`Please add a currency:`);
|
const currency = prompt($localize`Please add a currency:`);
|
||||||
|
|
||||||
if (currency) {
|
if (currency) {
|
||||||
const currencies = uniq([...this.customCurrencies, currency]);
|
if (currency.length === 3) {
|
||||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
const currencies = uniq([...this.customCurrencies, currency]);
|
||||||
|
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||||
|
} else {
|
||||||
|
alert($localize`${currency} is an invalid currency!`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
[matMenuTriggerFor]="exchangeRateActionsMenu"
|
[matMenuTriggerFor]="exchangeRateActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu
|
<mat-menu
|
||||||
#exchangeRateActionsMenu="matMenu"
|
#exchangeRateActionsMenu="matMenu"
|
||||||
@ -79,10 +79,7 @@
|
|||||||
[routerLink]="['/admin', 'market-data']"
|
[routerLink]="['/admin', 'market-data']"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
class="mr-2"
|
|
||||||
name="create-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Edit</span>
|
<span i18n>Edit</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@ -92,10 +89,7 @@
|
|||||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
class="mr-2"
|
|
||||||
name="trash-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Delete</span>
|
<span i18n>Delete</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -109,7 +103,7 @@
|
|||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onAddCurrency()"
|
(click)="onAddCurrency()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
<ion-icon class="mr-1" name="add-outline" />
|
||||||
<span i18n>Add Currency</span>
|
<span i18n>Add Currency</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -147,7 +141,7 @@
|
|||||||
mat-button
|
mat-button
|
||||||
(click)="onDeleteSystemMessage()"
|
(click)="onDeleteSystemMessage()"
|
||||||
>
|
>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<ion-icon name="trash-outline" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -157,10 +151,7 @@
|
|||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onSetSystemMessage()"
|
(click)="onSetSystemMessage()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon class="mr-1" name="information-circle-outline" />
|
||||||
class="mr-1"
|
|
||||||
name="information-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Set Message</span>
|
<span i18n>Set Message</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -182,7 +173,7 @@
|
|||||||
[matMenuTriggerFor]="couponActionsMenu"
|
[matMenuTriggerFor]="couponActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu
|
<mat-menu
|
||||||
#couponActionsMenu="matMenu"
|
#couponActionsMenu="matMenu"
|
||||||
@ -194,10 +185,7 @@
|
|||||||
(click)="onDeleteCoupon(coupon.code)"
|
(click)="onDeleteCoupon(coupon.code)"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
class="mr-2"
|
|
||||||
name="trash-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Delete</span>
|
<span i18n>Delete</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -240,7 +228,7 @@
|
|||||||
<div class="w-50" i18n>Housekeeping</div>
|
<div class="w-50" i18n>Housekeeping</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
||||||
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
|
<ion-icon class="mr-1" name="close-circle-outline" />
|
||||||
<span i18n>Flush Cache</span>
|
<span i18n>Flush Cache</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -82,18 +82,18 @@
|
|||||||
[matMenuTriggerFor]="platformMenu"
|
[matMenuTriggerFor]="platformMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
<span i18n>Edit</span>
|
<span i18n>Edit</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
<span i18n>Delete</span>
|
<span i18n>Delete</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -62,18 +62,18 @@
|
|||||||
[matMenuTriggerFor]="tagMenu"
|
[matMenuTriggerFor]="tagMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #tagMenu="matMenu" xPosition="before">
|
<mat-menu #tagMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onUpdateTag(element)">
|
<button mat-menu-item (click)="onUpdateTag(element)">
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
<ion-icon class="mr-2" name="create-outline" />
|
||||||
<span i18n>Edit</span>
|
<span i18n>Edit</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
<span i18n>Delete</span>
|
<span i18n>Delete</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -195,7 +195,7 @@
|
|||||||
[matMenuTriggerFor]="userMenu"
|
[matMenuTriggerFor]="userMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
@ -204,7 +204,7 @@
|
|||||||
(click)="onImpersonateUser(element.id)"
|
(click)="onImpersonateUser(element.id)"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
|
<ion-icon class="mr-2" name="contract-outline" />
|
||||||
<span i18n>Impersonate User</span>
|
<span i18n>Impersonate User</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -214,7 +214,7 @@
|
|||||||
(click)="onDeleteUser(element.id)"
|
(click)="onDeleteUser(element.id)"
|
||||||
>
|
>
|
||||||
<span class="align-items-center d-flex">
|
<span class="align-items-center d-flex">
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline" />
|
||||||
<span i18n>Delete User</span>
|
<span i18n>Delete User</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -3,5 +3,5 @@
|
|||||||
mat-button
|
mat-button
|
||||||
(click)="onClickCloseButton()"
|
(click)="onClickCloseButton()"
|
||||||
>
|
>
|
||||||
<ion-icon name="close" size="large"></ion-icon>
|
<ion-icon name="close" size="large" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -9,5 +9,5 @@
|
|||||||
mat-button
|
mat-button
|
||||||
(click)="onClickCloseButton()"
|
(click)="onClickCloseButton()"
|
||||||
>
|
>
|
||||||
<ion-icon name="close" size="large"></ion-icon>
|
<ion-icon name="close" size="large" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -119,7 +119,11 @@
|
|||||||
[matMenuTriggerRestoreFocus]="false"
|
[matMenuTriggerRestoreFocus]="false"
|
||||||
(menuOpened)="onOpenAssistant()"
|
(menuOpened)="onOpenAssistant()"
|
||||||
>
|
>
|
||||||
<ion-icon name="search-outline"></ion-icon>
|
@if (user?.settings?.isExperimentalFeatures) {
|
||||||
|
<ion-icon class="rotate-90" name="options-outline" />
|
||||||
|
} @else {
|
||||||
|
<ion-icon name="search-outline" />
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
<mat-menu
|
<mat-menu
|
||||||
#assistantMenu="matMenu"
|
#assistantMenu="matMenu"
|
||||||
@ -134,7 +138,10 @@
|
|||||||
[hasPermissionToAccessAdminControl]="
|
[hasPermissionToAccessAdminControl]="
|
||||||
hasPermissionToAccessAdminControl
|
hasPermissionToAccessAdminControl
|
||||||
"
|
"
|
||||||
|
[user]="user"
|
||||||
(closed)="closeAssistant()"
|
(closed)="closeAssistant()"
|
||||||
|
(dateRangeChanged)="onDateRangeChange($event)"
|
||||||
|
(selectedTagChanged)="onSelectedTagChanged($event)"
|
||||||
/>
|
/>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</li>
|
</li>
|
||||||
@ -150,12 +157,12 @@
|
|||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
name="person-circle-outline"
|
name="person-circle-outline"
|
||||||
size="large"
|
size="large"
|
||||||
></ion-icon>
|
/>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
size="large"
|
size="large"
|
||||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||||
></ion-icon>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<ng-container *ngIf="user?.access?.length > 0">
|
<ng-container *ngIf="user?.access?.length > 0">
|
||||||
@ -169,7 +176,7 @@
|
|||||||
? 'radio-button-off-outline'
|
? 'radio-button-off-outline'
|
||||||
: 'radio-button-on-outline'
|
: 'radio-button-on-outline'
|
||||||
"
|
"
|
||||||
></ion-icon>
|
/>
|
||||||
<span i18n>Me</span>
|
<span i18n>Me</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -187,7 +194,7 @@
|
|||||||
? 'radio-button-on-outline'
|
? 'radio-button-on-outline'
|
||||||
: 'radio-button-off-outline'
|
: 'radio-button-off-outline'
|
||||||
"
|
"
|
||||||
></ion-icon>
|
/>
|
||||||
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
||||||
<span *ngIf="!accessItem.alias" i18n>User</span>
|
<span *ngIf="!accessItem.alias" i18n>User</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -32,6 +32,10 @@
|
|||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|
||||||
|
&.rotate-90 {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,9 +19,12 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { InfoItem, 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 { DateRange } from '@ghostfolio/common/types';
|
||||||
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -88,7 +91,8 @@ export class HeaderComponent implements OnChanges {
|
|||||||
private impersonationStorageService: ImpersonationStorageService,
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private settingsStorageService: SettingsStorageService,
|
private settingsStorageService: SettingsStorageService,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService,
|
||||||
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
@ -144,6 +148,20 @@ export class HeaderComponent implements OnChanges {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onDateRangeChange(dateRange: DateRange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ dateRange })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onMenuClosed() {
|
public onMenuClosed() {
|
||||||
this.isMenuOpen = false;
|
this.isMenuOpen = false;
|
||||||
}
|
}
|
||||||
@ -156,6 +174,20 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.assistantElement.initialize();
|
this.assistantElement.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSelectedTagChanged(tag: Tag) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.signOut.next();
|
this.signOut.next();
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="container justify-content-center p-3">
|
<div class="container justify-content-center p-3">
|
||||||
<div class="mb-3 text-center">
|
<div *ngIf="!user?.settings?.isExperimentalFeatures" class="mb-3 text-center">
|
||||||
<gf-toggle
|
<gf-toggle
|
||||||
[defaultValue]="user?.settings?.dateRange"
|
[defaultValue]="user?.settings?.dateRange"
|
||||||
[isLoading]="positions === undefined"
|
[isLoading]="positions === undefined"
|
||||||
|
@ -96,7 +96,10 @@
|
|||||||
[showDetails]="showDetails"
|
[showDetails]="showDetails"
|
||||||
[unit]="unit"
|
[unit]="unit"
|
||||||
></gf-portfolio-performance>
|
></gf-portfolio-performance>
|
||||||
<div *ngIf="showDetails" class="text-center">
|
<div
|
||||||
|
*ngIf="showDetails && !user?.settings?.isExperimentalFeatures"
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
<gf-toggle
|
<gf-toggle
|
||||||
[defaultValue]="user?.settings?.dateRange"
|
[defaultValue]="user?.settings?.dateRange"
|
||||||
[isLoading]="isLoadingPerformance"
|
[isLoading]="isLoadingPerformance"
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
|
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
|
||||||
></ion-icon>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="errors?.length > 0 && !isLoading"
|
*ngIf="errors?.length > 0 && !isLoading"
|
||||||
name="alert-circle-outline"
|
name="alert-circle-outline"
|
||||||
></ion-icon>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="isLoading" class="align-items-center d-flex">
|
<div *ngIf="isLoading" class="align-items-center d-flex">
|
||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
|
@ -69,7 +69,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-nowrap px-3 py-1 row">
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
<div class="flex-grow-1 ml-3 text-truncate" i18n>Gross Performance</div>
|
<div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
|
||||||
|
<ng-container i18n>Gross Performance</ng-container>
|
||||||
|
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
|
||||||
|
</div>
|
||||||
<div class="flex-column flex-wrap justify-content-end">
|
<div class="flex-column flex-wrap justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
@ -112,7 +115,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-nowrap px-3 py-1 row">
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
<div class="flex-grow-1 text-truncate ml-3" i18n>Net Performance</div>
|
<div class="flex-grow-1 text-truncate ml-3">
|
||||||
|
<ng-container i18n>Net Performance</ng-container>
|
||||||
|
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
|
||||||
|
</div>
|
||||||
<div class="flex-column flex-wrap justify-content-end">
|
<div class="flex-column flex-wrap justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
@ -163,7 +169,7 @@
|
|||||||
*ngIf="hasPermissionToUpdateUserSettings && !isLoading"
|
*ngIf="hasPermissionToUpdateUserSettings && !isLoading"
|
||||||
class="mr-1 text-muted"
|
class="mr-1 text-muted"
|
||||||
name="ellipsis-horizontal-circle-outline"
|
name="ellipsis-horizontal-circle-outline"
|
||||||
></ion-icon>
|
/>
|
||||||
<gf-value
|
<gf-value
|
||||||
class="justify-content-end"
|
class="justify-content-end"
|
||||||
[isCurrency]="true"
|
[isCurrency]="true"
|
||||||
|
@ -7,12 +7,16 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Sort, SortDirection } from '@angular/material/sort';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
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 { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
DataProviderInfo,
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
LineChartItem
|
LineChartItem,
|
||||||
|
User
|
||||||
} 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 { translate } from '@ghostfolio/ui/i18n';
|
||||||
@ -31,6 +35,7 @@ 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 activities: OrderWithAccount[];
|
||||||
public assetClass: string;
|
public assetClass: string;
|
||||||
public assetSubClass: string;
|
public assetSubClass: string;
|
||||||
public averagePrice: number;
|
public averagePrice: number;
|
||||||
@ -39,6 +44,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
public dataProviderInfo: DataProviderInfo;
|
public dataProviderInfo: DataProviderInfo;
|
||||||
|
public dataSource: MatTableDataSource<OrderWithAccount>;
|
||||||
public dividendInBaseCurrency: number;
|
public dividendInBaseCurrency: number;
|
||||||
public feeInBaseCurrency: number;
|
public feeInBaseCurrency: number;
|
||||||
public firstBuyDate: string;
|
public firstBuyDate: string;
|
||||||
@ -51,16 +57,19 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
public minPrice: number;
|
public minPrice: number;
|
||||||
public netPerformance: number;
|
public netPerformance: number;
|
||||||
public netPerformancePercent: number;
|
public netPerformancePercent: number;
|
||||||
public orders: OrderWithAccount[];
|
|
||||||
public quantity: number;
|
public quantity: number;
|
||||||
public quantityPrecision = 2;
|
public quantityPrecision = 2;
|
||||||
public reportDataGlitchMail: string;
|
public reportDataGlitchMail: string;
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public sortColumn = 'date';
|
||||||
|
public sortDirection: SortDirection = 'desc';
|
||||||
public SymbolProfile: EnhancedSymbolProfile;
|
public SymbolProfile: EnhancedSymbolProfile;
|
||||||
public tags: Tag[];
|
public tags: Tag[];
|
||||||
|
public totalItems: number;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
|
public user: User;
|
||||||
public value: number;
|
public value: number;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -69,7 +78,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams,
|
||||||
|
private userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
@ -102,10 +112,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
transactionCount,
|
transactionCount,
|
||||||
value
|
value
|
||||||
}) => {
|
}) => {
|
||||||
|
this.activities = orders;
|
||||||
this.averagePrice = averagePrice;
|
this.averagePrice = averagePrice;
|
||||||
this.benchmarkDataItems = [];
|
this.benchmarkDataItems = [];
|
||||||
this.countries = {};
|
this.countries = {};
|
||||||
this.dataProviderInfo = dataProviderInfo;
|
this.dataProviderInfo = dataProviderInfo;
|
||||||
|
this.dataSource = new MatTableDataSource(orders.reverse());
|
||||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||||
this.firstBuyDate = firstBuyDate;
|
this.firstBuyDate = firstBuyDate;
|
||||||
@ -130,7 +142,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.minPrice = minPrice;
|
this.minPrice = minPrice;
|
||||||
this.netPerformance = netPerformance;
|
this.netPerformance = netPerformance;
|
||||||
this.netPerformancePercent = netPerformancePercent;
|
this.netPerformancePercent = netPerformancePercent;
|
||||||
this.orders = orders;
|
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||||
this.sectors = {};
|
this.sectors = {};
|
||||||
@ -142,6 +153,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
|
this.totalItems = transactionCount;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
|
||||||
if (SymbolProfile?.assetClass) {
|
if (SymbolProfile?.assetClass) {
|
||||||
@ -239,6 +251,16 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onClose(): void {
|
public onClose(): void {
|
||||||
@ -246,12 +268,20 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onExport() {
|
public onExport() {
|
||||||
|
let activityIds = [];
|
||||||
|
|
||||||
|
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||||
|
activityIds = this.dataSource.data.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
activityIds = this.activities.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchExport(
|
.fetchExport(activityIds)
|
||||||
this.orders.map((order) => {
|
|
||||||
return order.id;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
downloadAsFile({
|
downloadAsFile({
|
||||||
|
@ -246,11 +246,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
<div class="row" [ngClass]="{ 'd-none': !activities?.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-lazy
|
||||||
|
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||||
|
[baseCurrency]="data.baseCurrency"
|
||||||
|
[dataSource]="dataSource"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[hasPermissionToCreateActivity]="false"
|
||||||
|
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||||
|
[hasPermissionToFilter]="false"
|
||||||
|
[hasPermissionToOpenDetails]="false"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[showActions]="false"
|
||||||
|
[showNameColumn]="false"
|
||||||
|
[sortColumn]="sortColumn"
|
||||||
|
[sortDirection]="sortDirection"
|
||||||
|
[sortDisabled]="true"
|
||||||
|
[totalItems]="totalItems"
|
||||||
|
(export)="onExport()"
|
||||||
|
></gf-activities-table-lazy>
|
||||||
<gf-activities-table
|
<gf-activities-table
|
||||||
[activities]="orders"
|
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||||
|
[activities]="activities"
|
||||||
[baseCurrency]="data.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[deviceType]="data.deviceType"
|
[deviceType]="data.deviceType"
|
||||||
[hasPermissionToCreateActivity]="false"
|
[hasPermissionToCreateActivity]="false"
|
||||||
@ -277,7 +296,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
|
*ngIf="activities?.length > 0 && data.hasPermissionToReportDataGlitch === true"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
@ -5,6 +5,7 @@ import { MatChipsModule } from '@angular/material/chips';
|
|||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
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 { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
|
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
@ -19,6 +20,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfActivitiesTableModule,
|
GfActivitiesTableModule,
|
||||||
|
GfActivitiesTableLazyModule,
|
||||||
GfDataProviderCreditsModule,
|
GfDataProviderCreditsModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
|
@ -65,7 +65,7 @@
|
|||||||
class="chevron text-muted"
|
class="chevron text-muted"
|
||||||
name="chevron-forward-outline"
|
name="chevron-forward-outline"
|
||||||
size="small"
|
size="small"
|
||||||
></ion-icon>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user