Compare commits
122 Commits
Author | SHA1 | Date | |
---|---|---|---|
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f | |||
67dbef3b7a | |||
0e94112dc7 | |||
b22edff16b | |||
ffb7cbff50 | |||
25424ad280 | |||
a768902b00 | |||
2c7ece50fe | |||
51a0ede3e4 | |||
531964636b | |||
e461fff1d7 | |||
4f9a5f0340 | |||
8d80e840b8 | |||
833982a9de | |||
c85966e5ed | |||
43f67ba832 | |||
cbea8ac9d3 | |||
d4c939e41d | |||
c1f129501a | |||
377ba75e4c | |||
77b13b88f0 | |||
813e73a0a3 | |||
1d796a9597 | |||
4eedf64a3c | |||
ed4dd79c72 | |||
6f4fd0826c | |||
8e3a144a37 | |||
07b0a2c40a | |||
c5dc3d4272 | |||
73e69273b4 | |||
e0b74ef418 | |||
2b491dc732 | |||
79fc22b5ae | |||
0a83bcd697 | |||
52540d460b | |||
6ff2e0f952 | |||
b3e72383bc | |||
bdfba4d509 | |||
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b | |||
c758ca4bfa | |||
37183a07bd | |||
fb294fc6e2 | |||
8898d02442 | |||
232d30234c | |||
e2234c4966 | |||
272a34195b | |||
8c25294da7 | |||
6f11627006 | |||
215098e418 | |||
781496383b | |||
f0f304c012 | |||
4bf97c104b | |||
0b35a3c7a7 | |||
1586cd3a59 | |||
ae763cbb87 | |||
aa72287d54 | |||
d155ab6f28 | |||
913ca71aa5 | |||
1ffde2a27e | |||
fcf0cea982 | |||
ae1968aadf | |||
3e6333ef95 | |||
c69686651e | |||
93b6011ddc | |||
f567e25f27 | |||
5dc538bafb | |||
b4de06fcf0 | |||
27da0eb26e | |||
8ff80c10e5 | |||
5db5d5e79a | |||
12aac101bd | |||
3a66ccdebe | |||
6a722d1bb7 | |||
7c9407d5dc | |||
8abb517ac6 | |||
dec1d89c5c | |||
24e9ecc3e2 | |||
4a1e05b8cd | |||
39d1a85267 | |||
7cb86de7af | |||
aa078588e8 | |||
fcef0a72d5 | |||
29987d3e2f | |||
6284b4dfe8 | |||
00342ca1f7 | |||
234c4fd511 | |||
669f1fb60c | |||
52df0c62ab | |||
e8e1bb83bf | |||
45510702d0 | |||
1b7e3a1e47 | |||
35f98b9d2d | |||
e980aed9e7 | |||
d993067e9a | |||
3d09bfdb0c | |||
3fbc4f500f | |||
373201a98f | |||
681f88f002 | |||
8a523a981a | |||
81ded53363 | |||
5272407af8 | |||
c48f89d117 |
7
.github/workflows/build-code.yml
vendored
7
.github/workflows/build-code.yml
vendored
@ -4,6 +4,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -13,12 +16,12 @@ jobs:
|
|||||||
- 18
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node_version }}
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
|
3
.github/workflows/docker-image.yml
vendored
3
.github/workflows/docker-image.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
@ -21,6 +21,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghostfolio/ghostfolio
|
images: ghostfolio/ghostfolio
|
||||||
tags: |
|
tags: |
|
||||||
|
type=semver,pattern={{major}}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
209
CHANGELOG.md
209
CHANGELOG.md
@ -5,6 +5,203 @@ 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.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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for column sorting to the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||||
|
- Extended the benchmarks of the markets overview by the current market condition (all time high)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Adjusted the threshold to skip the data enhancement (_Trackinsight_) if data is inaccurate
|
||||||
|
- Upgraded `prisma` from version `5.6.0` to `5.7.0`
|
||||||
|
|
||||||
|
## 2.29.0 - 2023-12-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced a lazy-loaded activities table on the portfolio activities page (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set the actions columns of various tables to stick at the end
|
||||||
|
- Increased the height of the tabs on mobile
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Türkçe (`tr`)
|
||||||
|
- Upgraded `marked` from version `4.2.12` to `9.1.6`
|
||||||
|
- Upgraded `ngx-markdown` from version `15.1.0` to `17.1.1`
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.8.3` to `2.9.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the biometric authentication registration
|
||||||
|
|
||||||
|
## 2.28.0 - 2023-12-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a historical cash balances table to the account detail dialog
|
||||||
|
- Introduced a `HasPermission` annotation for endpoints
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Relaxed the check for duplicates in the preview step of the activities import (allow same day)
|
||||||
|
- Respected the `withExcludedAccounts` flag in the account balance time series
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks
|
||||||
|
|
||||||
|
## 2.27.1 - 2023-11-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reverted `Nx` from version `17.1.3` to `17.0.2`
|
||||||
|
|
||||||
|
## 2.27.0 - 2023-11-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the chart in the account detail dialog by historical cash balances
|
||||||
|
- Improved the error log for a timeout in the data source request
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `16.2.12` to `17.0.4`
|
||||||
|
- Upgraded `Nx` from version `17.0.2` to `17.1.3`
|
||||||
|
|
||||||
|
## 2.26.0 - 2023-11-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `prisma` from version `5.5.2` to `5.6.0`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.8.1` to `2.9.0`
|
||||||
|
|
||||||
|
## 2.25.1 - 2023-11-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Black Friday 2023_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `http-status-codes` from version `2.2.0` to `2.3.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handled reading items from missing transaction point while getting the position (`getPosition()`) in portfolio service
|
||||||
|
|
||||||
|
## 2.24.0 - 2023-11-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the "too many bind variables in prepared statement" issue of the data range functionality (`getRange()`) in the market data service
|
||||||
|
|
||||||
|
## 2.23.0 - 2023-11-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the benchmarks in the markets overview by 50-Day and 200-Day trends (experimental)
|
||||||
|
- Set up the language localization for Polski (`pl`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the data source validation in the activities import
|
||||||
|
- Changed _Twitter_ to _𝕏_
|
||||||
|
- Improved the selection in the twitter bot service
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.7.0` to `2.8.3`
|
||||||
|
- Upgraded `prettier` from version `3.0.3` to `3.1.0`
|
||||||
|
|
||||||
|
## 2.22.0 - 2023-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the platform icon to the account selectors in the cash balance transfer from one to another account
|
||||||
|
- Added the platform icon to the account selector of the create or edit activity dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the style of the carousel component on mobile for the testimonial section on the landing page
|
||||||
|
- Introduced action menus in the overview of the admin control panel
|
||||||
|
- Harmonized the name column in the historical market data table of the admin control panel
|
||||||
|
- Refactored the implementation of the data range functionality (`getRange()`) in the market data service
|
||||||
|
|
||||||
|
## 2.21.0 - 2023-11-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the system message
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the unit for the _Zen Mode_ in the overview tab of the home page
|
||||||
|
- Fixed an issue to get quotes in the _Financial Modeling Prep_ service
|
||||||
|
|
||||||
|
## 2.20.0 - 2023-11-08
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the loading indicator of the unit in the overview tab of the home page
|
||||||
|
- Improved the import of historical market data in the admin control panel
|
||||||
|
- Increased the timeout in the health check endpoint for data enhancers
|
||||||
|
- Increased the timeout in the health check endpoint for data providers
|
||||||
|
- Removed the account type from the `Account` database schema
|
||||||
|
|
||||||
|
## 2.19.0 - 2023-11-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a data migration to set `accountType` to `NULL` in the account database table
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for the _Fear & Greed Index_ (market mood)
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the handling of derived currencies (`GBp`, `ILA`, `ZAc`)
|
||||||
|
|
||||||
|
## 2.18.0 - 2023-11-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to import activities by `isin` in the _Yahoo Finance_ service
|
||||||
|
- Added a new tag with the major version to the docker image on _Docker Hub_
|
||||||
|
- Added a blog post: _Hacktoberfest 2023 Debriefing_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `16.2.1` to `16.2.12`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue to get quotes in the _CoinGecko_ service
|
||||||
|
- Loosened the validation in the activities import (expects values greater than or equal to 0 for `fee`, `quantity` and `unitPrice`)
|
||||||
|
- Handled an issue with a failing database query (`account.findMany()`) related to activities without account
|
||||||
|
|
||||||
## 2.17.0 - 2023-11-02
|
## 2.17.0 - 2023-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -24,7 +221,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
|
- Relaxed the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||||
- Improved the usability and validation in the cash balance transfer from one to another account
|
- Improved the usability and validation in the cash balance transfer from one to another account
|
||||||
- Changed the checkboxes to slide toggles in the overview of the admin control panel
|
- Changed the checkboxes to slide toggles in the overview of the admin control panel
|
||||||
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
|
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
|
||||||
@ -116,7 +313,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added support to transfer a part of the cash balance from one to another account
|
- Added support to transfer a part of the cash balance from one to another account
|
||||||
- Extended the markets overview by benchmarks (date of last all time high)
|
- Extended the benchmarks in the markets overview by the date of the last all time high
|
||||||
- Added support to import historical market data in the admin control panel
|
- Added support to import historical market data in the admin control panel
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -328,7 +525,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added health check endpoints for data enhancers
|
- Added a health check endpoint for data enhancers
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -504,7 +701,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the usability of the login dialog
|
- Improved the usability of the login dialog
|
||||||
- Disabled the caching in the health check endpoints for data providers
|
- Disabled the caching in the health check endpoint for data providers
|
||||||
- Improved the content of the Frequently Asked Questions (FAQ) page
|
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||||
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
|
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
|
||||||
|
|
||||||
@ -892,7 +1089,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added a fallback to historical market data if a data provider does not provide live data
|
- Added a fallback to historical market data if a data provider does not provide live data
|
||||||
- Added a general health check endpoint
|
- Added a general health check endpoint
|
||||||
- Added health check endpoints for data providers
|
- Added a health check endpoint for data providers
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -2356,7 +2553,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added the _Ghostfolio_ trailer to the landing page
|
- Added the _Ghostfolio_ trailer to the landing page
|
||||||
- Extended the markets overview by benchmarks (current change to the all time high)
|
- Extended the benchmarks in the markets overview by the current change to the all time high
|
||||||
|
|
||||||
## 1.151.0 - 24.05.2022
|
## 1.151.0 - 24.05.2022
|
||||||
|
|
||||||
|
@ -20,13 +20,19 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
### Angular
|
||||||
|
|
||||||
|
#### Upgrade (minor versions)
|
||||||
|
|
||||||
|
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
|
||||||
|
|
||||||
### Nx
|
### Nx
|
||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `yarn nx migrate latest`
|
1. Run `yarn nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
1. Run `yarn nx migrate --run-migrations`
|
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
|
26
README.md
26
README.md
@ -230,18 +230,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | -------------------------------------------------- |
|
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
| comment | string (`optional`) | Comment of the activity |
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
| fee | number | Fee of the activity |
|
| fee | number | Fee of the activity |
|
||||||
| quantity | number | Quantity of the activity |
|
| quantity | number | Quantity of the activity |
|
||||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| unitPrice | number | Price per unit of the activity |
|
| unitPrice | number | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -272,7 +272,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
|
@ -14,7 +14,8 @@
|
|||||||
"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": {
|
||||||
@ -47,8 +48,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@nx/jest:jest",
|
"executor": "@nx/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.ts",
|
"jestConfig": "apps/api/jest.config.ts"
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
},
|
||||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,6 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Access as AccessModel } from '@prisma/client';
|
import { Access as AccessModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccessModule } from './access.module';
|
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
import { CreateAccessDto } from './create-access.dto';
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@ -83,7 +82,7 @@ export class AccessController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
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 (
|
||||||
|
@ -0,0 +1,51 @@
|
|||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { AccountBalance } from '@prisma/client';
|
||||||
|
|
||||||
|
@Controller('account-balance')
|
||||||
|
export class AccountBalanceController {
|
||||||
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteAccountBalance(
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.deleteAccountBalance
|
||||||
|
) ||
|
||||||
|
!accountBalance ||
|
||||||
|
accountBalance.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
14
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
14
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AccountBalanceController } from './account-balance.controller';
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AccountBalanceController],
|
||||||
|
exports: [AccountBalanceService],
|
||||||
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
|
providers: [AccountBalanceService]
|
||||||
|
})
|
||||||
|
export class AccountBalanceModule {}
|
91
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
91
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountBalanceService {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async accountBalance(
|
||||||
|
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
|
||||||
|
): Promise<AccountBalance | null> {
|
||||||
|
return this.prismaService.accountBalance.findFirst({
|
||||||
|
include: {
|
||||||
|
Account: true
|
||||||
|
},
|
||||||
|
where: accountBalanceWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAccountBalance(
|
||||||
|
data: Prisma.AccountBalanceCreateInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccountBalance(
|
||||||
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAccountBalances({
|
||||||
|
filters,
|
||||||
|
user,
|
||||||
|
withExcludedAccounts
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
user: UserWithSettings;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
|
}): Promise<AccountBalancesResponse> {
|
||||||
|
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
||||||
|
|
||||||
|
const accountFilter = filters?.find(({ type }) => {
|
||||||
|
return type === 'ACCOUNT';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accountFilter) {
|
||||||
|
where.accountId = accountFilter.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.Account = { isExcluded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this.prismaService.accountBalance.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
Account: true,
|
||||||
|
date: true,
|
||||||
|
id: true,
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances: balances.map((balance) => {
|
||||||
|
return {
|
||||||
|
...balance,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
balance.value,
|
||||||
|
balance.Account.currency,
|
||||||
|
user.Settings.settings.baseCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
|
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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@ -128,8 +128,8 @@ export class AccountController {
|
|||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalancesResponse> {
|
): Promise<AccountBalancesResponse> {
|
||||||
return this.accountBalanceService.getAccountBalances({
|
return this.accountBalanceService.getAccountBalances({
|
||||||
accountId: id,
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
userId: this.request.user.id
|
user: this.request.user
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -10,10 +9,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountType?: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -10,10 +9,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountType?: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@ -9,17 +9,22 @@ import {
|
|||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
calculateBenchmarkTrend
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
Benchmark,
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkProperty,
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } 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 } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@ -45,9 +50,34 @@ export class BenchmarkService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||||
BenchmarkResponse['benchmarks']
|
const historicalData = await this.marketDataService.marketDataItems({
|
||||||
> {
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: { gte: subDays(new Date(), 400) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fiftyDayAverage = calculateBenchmarkTrend({
|
||||||
|
historicalData,
|
||||||
|
days: 50
|
||||||
|
});
|
||||||
|
const twoHundredDayAverage = calculateBenchmarkTrend({
|
||||||
|
historicalData,
|
||||||
|
days: 200
|
||||||
|
});
|
||||||
|
|
||||||
|
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarks({
|
||||||
|
enableSharing = false,
|
||||||
|
useCache = true
|
||||||
|
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
@ -62,9 +92,16 @@ export class BenchmarkService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||||
|
enableSharing
|
||||||
|
});
|
||||||
|
|
||||||
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||||
|
[];
|
||||||
|
const promisesBenchmarkTrends: Promise<{
|
||||||
|
trend50d: BenchmarkTrend;
|
||||||
|
trend200d: BenchmarkTrend;
|
||||||
|
}>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
@ -73,10 +110,18 @@ export class BenchmarkService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
promisesAllTimeHighs.push(
|
||||||
|
this.marketDataService.getMax({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
promisesBenchmarkTrends.push(
|
||||||
|
this.getBenchmarkTrends({ dataSource, symbol })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTimeHighs = await Promise.all(promises);
|
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||||
|
Promise.all(promisesAllTimeHighs),
|
||||||
|
Promise.all(promisesBenchmarkTrends)
|
||||||
|
]);
|
||||||
let storeInCache = true;
|
let storeInCache = true;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
@ -93,6 +138,7 @@ export class BenchmarkService {
|
|||||||
} else {
|
} else {
|
||||||
storeInCache = false;
|
storeInCache = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
@ -100,10 +146,12 @@ export class BenchmarkService {
|
|||||||
name: benchmarkAssetProfiles[index].name,
|
name: benchmarkAssetProfiles[index].name,
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
date: allTimeHigh.date,
|
date: allTimeHigh?.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -118,14 +166,24 @@ export class BenchmarkService {
|
|||||||
return benchmarks;
|
return benchmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
public async getBenchmarkAssetProfiles({
|
||||||
|
enableSharing = false
|
||||||
|
} = {}): Promise<Partial<SymbolProfile>[]> {
|
||||||
const symbolProfileIds: string[] = (
|
const symbolProfileIds: string[] = (
|
||||||
((await this.propertyService.getByKey(
|
((await this.propertyService.getByKey(
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
)) as BenchmarkProperty[]) ?? []
|
)) as BenchmarkProperty[]) ?? []
|
||||||
).map(({ symbolProfileId }) => {
|
)
|
||||||
return symbolProfileId;
|
.filter((benchmark) => {
|
||||||
});
|
if (enableSharing) {
|
||||||
|
return benchmark.enableSharing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
});
|
||||||
|
|
||||||
const assetProfiles =
|
const assetProfiles =
|
||||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||||
@ -282,7 +340,15 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
private getMarketCondition(
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
aPerformanceInPercent: number
|
||||||
|
): Benchmark['marketCondition'] {
|
||||||
|
if (aPerformanceInPercent === 0) {
|
||||||
|
return 'ALL_TIME_HIGH';
|
||||||
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
|
return 'BEAR_MARKET';
|
||||||
|
} else {
|
||||||
|
return 'NEUTRAL_MARKET';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ export class ExportController {
|
|||||||
): 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) {
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -25,7 +26,7 @@ import {
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
@ -81,12 +83,13 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
const date = parseDate(dateString);
|
||||||
const isDuplicate = orders.some((activity) => {
|
const isDuplicate = orders.some((activity) => {
|
||||||
return (
|
return (
|
||||||
activity.accountId === Account?.id &&
|
activity.accountId === Account?.id &&
|
||||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
isSameDay(activity.date, parseDate(dateString)) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||||
activity.type === 'DIVIDEND' &&
|
activity.type === 'DIVIDEND' &&
|
||||||
@ -100,6 +103,7 @@ export class ImportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
Account,
|
Account,
|
||||||
|
date,
|
||||||
error,
|
error,
|
||||||
quantity,
|
quantity,
|
||||||
value,
|
value,
|
||||||
@ -107,7 +111,6 @@ export class ImportService {
|
|||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
date: parseDate(dateString),
|
|
||||||
fee: 0,
|
fee: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
@ -233,6 +236,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -456,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(
|
||||||
@ -480,13 +487,13 @@ export class ImportService {
|
|||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
const date = parseISO(<string>(<unknown>dateString));
|
const date = parseISO(dateString);
|
||||||
const isDuplicate = existingActivities.some((activity) => {
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
return (
|
return (
|
||||||
activity.accountId === accountId &&
|
activity.accountId === accountId &&
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameDay(activity.date, date) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.fee === fee &&
|
activity.fee === fee &&
|
||||||
activity.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
activity.SymbolProfile.symbol === symbol &&
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
@ -570,6 +577,12 @@ export class ImportService {
|
|||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol }
|
{ currency, dataSource, symbol }
|
||||||
] of uniqueActivitiesDto.entries()) {
|
] of uniqueActivitiesDto.entries()) {
|
||||||
|
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const assetProfile = (
|
const assetProfile = (
|
||||||
await this.dataProviderService.getAssetProfiles([
|
await this.dataProviderService.getAssetProfiles([
|
||||||
|
@ -15,7 +15,6 @@ import {
|
|||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
@ -58,7 +57,6 @@ export class InfoService {
|
|||||||
const platforms = await this.platformService.getPlatforms({
|
const platforms = await this.platformService.getPlatforms({
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { name: 'asc' }
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
|
||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
@ -104,10 +102,6 @@ export class InfoService {
|
|||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||||
globalPermissions.push(permissions.enableSystemMessage);
|
globalPermissions.push(permissions.enableSystemMessage);
|
||||||
|
|
||||||
systemMessage = (await this.propertyService.getByKey(
|
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
|
||||||
)) as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUserSignupEnabled =
|
const isUserSignupEnabled =
|
||||||
@ -135,7 +129,6 @@ export class InfoService {
|
|||||||
platforms,
|
platforms,
|
||||||
statistics,
|
statistics,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
systemMessage,
|
|
||||||
tags,
|
tags,
|
||||||
baseCurrency: DEFAULT_CURRENCY,
|
baseCurrency: DEFAULT_CURRENCY,
|
||||||
currencies: this.exchangeRateDataService.getCurrencies()
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
|
@ -13,7 +13,6 @@ import {
|
|||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@ -54,7 +53,7 @@ export class CreateOrderDto {
|
|||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsPositive()
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -68,7 +67,7 @@ export class CreateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsPositive()
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
export interface Activities {
|
export interface Activities {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
|
@ -24,7 +24,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Order as OrderModel } from '@prisma/client';
|
import { Order as OrderModel, Prisma } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -90,6 +90,8 @@ export class OrderController {
|
|||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
@ -103,8 +105,10 @@ export class OrderController {
|
|||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
skip: isNaN(skip) ? undefined : skip,
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
@ -113,7 +117,7 @@ export class OrderController {
|
|||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
@ -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 { 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.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;
|
||||||
@ -231,6 +203,8 @@ export class OrderService {
|
|||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
skip,
|
skip,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
take = Number.MAX_SAFE_INTEGER,
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -240,12 +214,17 @@ export class OrderService {
|
|||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
take?: number;
|
take?: number;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activities> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||||
|
{ date: 'asc' }
|
||||||
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -307,6 +286,10 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortColumn) {
|
||||||
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.OR = types.map((type) => {
|
where.OR = types.map((type) => {
|
||||||
return {
|
return {
|
||||||
@ -317,8 +300,9 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const [orders, count] = await Promise.all([
|
||||||
await this.orders({
|
this.orders({
|
||||||
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
@ -332,10 +316,12 @@ export class OrderService {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
SymbolProfile: true,
|
SymbolProfile: true,
|
||||||
tags: true
|
tags: true
|
||||||
},
|
}
|
||||||
orderBy: { date: 'asc' }
|
}),
|
||||||
})
|
this.prismaService.order.count({ where })
|
||||||
)
|
]);
|
||||||
|
|
||||||
|
const activities = orders
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return (
|
return (
|
||||||
withExcludedAccounts ||
|
withExcludedAccounts ||
|
||||||
@ -361,6 +347,16 @@ export class OrderService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
@ -439,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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,10 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@ -56,7 +54,7 @@ export class UpdateOrderDto {
|
|||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsPositive()
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -70,6 +68,6 @@ export class UpdateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsPositive()
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -61,6 +61,7 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
values.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
date
|
date
|
||||||
@ -74,6 +75,7 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
values.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
date
|
date
|
||||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -25,30 +26,30 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
|||||||
getRange: ({
|
getRange: ({
|
||||||
dateRangeEnd,
|
dateRangeEnd,
|
||||||
dateRangeStart,
|
dateRangeStart,
|
||||||
symbols
|
uniqueAssets
|
||||||
}: {
|
}: {
|
||||||
dateRangeEnd: Date;
|
dateRangeEnd: Date;
|
||||||
dateRangeStart: Date;
|
dateRangeStart: Date;
|
||||||
symbols: string[];
|
uniqueAssets: UniqueAsset[];
|
||||||
}) => {
|
}) => {
|
||||||
return Promise.resolve<MarketData[]>([
|
return Promise.resolve<MarketData[]>([
|
||||||
{
|
{
|
||||||
createdAt: dateRangeStart,
|
createdAt: dateRangeStart,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: uniqueAssets[0].dataSource,
|
||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
state: 'CLOSE',
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: uniqueAssets[0].symbol
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createdAt: dateRangeEnd,
|
createdAt: dateRangeEnd,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: uniqueAssets[0].dataSource,
|
||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
state: 'CLOSE',
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: uniqueAssets[0].symbol
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -134,6 +135,7 @@ describe('CurrentRateService', () => {
|
|||||||
errors: [],
|
errors: [],
|
||||||
values: [
|
values: [
|
||||||
{
|
{
|
||||||
|
dataSource: 'YAHOO',
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
|
@ -2,7 +2,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
ResponseError,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||||
@ -52,6 +56,7 @@ export class CurrentRateService {
|
|||||||
|
|
||||||
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||||
result.push({
|
result.push({
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
date: today,
|
date: today,
|
||||||
marketPriceInBaseCurrency:
|
marketPriceInBaseCurrency:
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
@ -75,27 +80,30 @@ export class CurrentRateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
|
||||||
return dataGatheringItem.symbol;
|
({ dataSource, symbol }) => {
|
||||||
});
|
return { dataSource, symbol };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.marketDataService
|
this.marketDataService
|
||||||
.getRange({
|
.getRange({
|
||||||
dateQuery,
|
dateQuery,
|
||||||
symbols
|
uniqueAssets
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data.map((marketDataItem) => {
|
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
||||||
return {
|
return {
|
||||||
date: marketDataItem.date,
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol,
|
||||||
marketPriceInBaseCurrency:
|
marketPriceInBaseCurrency:
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
marketDataItem.marketPrice,
|
marketPrice,
|
||||||
currencies[marketDataItem.symbol],
|
currencies[symbol],
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
)
|
||||||
symbol: marketDataItem.symbol
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -112,7 +120,7 @@ export class CurrentRateService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isEmpty(quoteErrors)) {
|
if (!isEmpty(quoteErrors)) {
|
||||||
for (const { symbol } of quoteErrors) {
|
for (const { dataSource, symbol } of quoteErrors) {
|
||||||
try {
|
try {
|
||||||
// If missing quote, fallback to the latest available historical market price
|
// If missing quote, fallback to the latest available historical market price
|
||||||
let value: GetValueObject = response.values.find((currentValue) => {
|
let value: GetValueObject = response.values.find((currentValue) => {
|
||||||
@ -121,6 +129,7 @@ export class CurrentRateService {
|
|||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
value = {
|
value = {
|
||||||
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: today,
|
date: today,
|
||||||
marketPriceInBaseCurrency: 0
|
marketPriceInBaseCurrency: 0
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export interface GetValueObject {
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface GetValueObject extends UniqueAsset {
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPriceInBaseCurrency: number;
|
marketPriceInBaseCurrency: number;
|
||||||
symbol: string;
|
|
||||||
}
|
}
|
||||||
|
@ -346,16 +346,34 @@ export class PortfolioController {
|
|||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performanceInformation.chart = performanceInformation.chart.map(
|
performanceInformation.chart = performanceInformation.chart.map(
|
||||||
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
({
|
||||||
|
date,
|
||||||
|
netPerformanceInPercentage,
|
||||||
|
netWorth,
|
||||||
|
totalInvestment,
|
||||||
|
value
|
||||||
|
}) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
totalInvestment: new Big(totalInvestment)
|
netWorthInPercentage:
|
||||||
.div(performanceInformation.performance.totalInvestment)
|
performanceInformation.performance.currentNetWorth === 0
|
||||||
.toNumber(),
|
? 0
|
||||||
valueInPercentage: new Big(value)
|
: new Big(netWorth)
|
||||||
.div(performanceInformation.performance.currentValue)
|
.div(performanceInformation.performance.currentNetWorth)
|
||||||
.toNumber()
|
.toNumber(),
|
||||||
|
totalInvestment:
|
||||||
|
performanceInformation.performance.totalInvestment === 0
|
||||||
|
? 0
|
||||||
|
: new Big(totalInvestment)
|
||||||
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
|
.toNumber(),
|
||||||
|
valueInPercentage:
|
||||||
|
performanceInformation.performance.currentValue === 0
|
||||||
|
? 0
|
||||||
|
: new Big(value)
|
||||||
|
.div(performanceInformation.performance.currentValue)
|
||||||
|
.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -365,6 +383,7 @@ export class PortfolioController {
|
|||||||
[
|
[
|
||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
|
'currentNetWorth',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
'totalInvestment'
|
'totalInvestment'
|
||||||
]
|
]
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
@ -67,14 +68,16 @@ import {
|
|||||||
isBefore,
|
isBefore,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isSameYear,
|
isSameYear,
|
||||||
|
isValid,
|
||||||
max,
|
max,
|
||||||
|
min,
|
||||||
parseISO,
|
parseISO,
|
||||||
set,
|
set,
|
||||||
setDayOfYear,
|
setDayOfYear,
|
||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
|
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
@ -91,6 +94,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly currentRateService: CurrentRateService,
|
private readonly currentRateService: CurrentRateService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
@ -114,8 +118,12 @@ export class PortfolioService {
|
|||||||
}): Promise<AccountWithValue[]> {
|
}): Promise<AccountWithValue[]> {
|
||||||
const where: Prisma.AccountWhereInput = { userId: userId };
|
const where: Prisma.AccountWhereInput = { userId: userId };
|
||||||
|
|
||||||
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') {
|
const accountFilter = filters?.find(({ type }) => {
|
||||||
where.id = filters[0].id;
|
return type === 'ACCOUNT';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accountFilter) {
|
||||||
|
where.id = accountFilter.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [accounts, details] = await Promise.all([
|
const [accounts, details] = await Promise.all([
|
||||||
@ -217,7 +225,7 @@ export class PortfolioService {
|
|||||||
}): Promise<InvestmentItem[]> {
|
}): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const { activities } = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userId,
|
userId,
|
||||||
types: ['DIVIDEND'],
|
types: ['DIVIDEND'],
|
||||||
@ -267,6 +275,13 @@ export class PortfolioService {
|
|||||||
includeDrafts: true
|
includeDrafts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (transactionPoints.length === 0) {
|
||||||
|
return {
|
||||||
|
investments: [],
|
||||||
|
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currency: this.request.user.Settings.settings.baseCurrency,
|
currency: this.request.user.Settings.settings.baseCurrency,
|
||||||
currentRateService: this.currentRateService,
|
currentRateService: this.currentRateService,
|
||||||
@ -274,12 +289,6 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
|
||||||
return {
|
|
||||||
investments: [],
|
|
||||||
streaks: { currentStreak: 0, longestStreak: 0 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
|
|
||||||
@ -367,67 +376,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart({
|
|
||||||
dateRange = 'max',
|
|
||||||
filters,
|
|
||||||
impersonationId,
|
|
||||||
userCurrency,
|
|
||||||
userId,
|
|
||||||
withExcludedAccounts = false
|
|
||||||
}: {
|
|
||||||
dateRange?: DateRange;
|
|
||||||
filters?: Filter[];
|
|
||||||
impersonationId: string;
|
|
||||||
userCurrency: string;
|
|
||||||
userId: string;
|
|
||||||
withExcludedAccounts?: boolean;
|
|
||||||
}): Promise<HistoricalDataContainer> {
|
|
||||||
userId = await this.getUserId(impersonationId, userId);
|
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
|
||||||
await this.getTransactionPoints({
|
|
||||||
filters,
|
|
||||||
userId,
|
|
||||||
withExcludedAccounts
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currency: userCurrency,
|
|
||||||
currentRateService: this.currentRateService,
|
|
||||||
orders: portfolioOrders
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
|
||||||
if (transactionPoints.length === 0) {
|
|
||||||
return {
|
|
||||||
isAllTimeHigh: false,
|
|
||||||
isAllTimeLow: false,
|
|
||||||
items: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const endDate = new Date();
|
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
|
||||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
|
||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), startDate);
|
|
||||||
const step = Math.round(
|
|
||||||
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = await portfolioCalculator.getChartData(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
step
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
isAllTimeHigh: false,
|
|
||||||
isAllTimeLow: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDetails({
|
public async getDetails({
|
||||||
dateRange = 'max',
|
dateRange = 'max',
|
||||||
filters,
|
filters,
|
||||||
@ -731,13 +679,13 @@ export class PortfolioService {
|
|||||||
const user = await this.userService.user({ id: userId });
|
const user = await this.userService.user({ id: userId });
|
||||||
const userCurrency = this.getUserCurrency(user);
|
const userCurrency = this.getUserCurrency(user);
|
||||||
|
|
||||||
const orders = (
|
const { activities } = await this.orderService.getOrders({
|
||||||
await this.orderService.getOrders({
|
userCurrency,
|
||||||
userCurrency,
|
userId,
|
||||||
userId,
|
withExcludedAccounts: true
|
||||||
withExcludedAccounts: true
|
});
|
||||||
})
|
|
||||||
).filter(({ SymbolProfile }) => {
|
const orders = activities.filter(({ SymbolProfile }) => {
|
||||||
return (
|
return (
|
||||||
SymbolProfile.dataSource === aDataSource &&
|
SymbolProfile.dataSource === aDataSource &&
|
||||||
SymbolProfile.symbol === aSymbol
|
SymbolProfile.symbol === aSymbol
|
||||||
@ -879,7 +827,7 @@ export class PortfolioService {
|
|||||||
let currentAveragePrice = 0;
|
let currentAveragePrice = 0;
|
||||||
let currentQuantity = 0;
|
let currentQuantity = 0;
|
||||||
|
|
||||||
const currentSymbol = transactionPoints[j].items.find(
|
const currentSymbol = transactionPoints[j]?.items.find(
|
||||||
({ symbol }) => {
|
({ symbol }) => {
|
||||||
return symbol === aSymbol;
|
return symbol === aSymbol;
|
||||||
}
|
}
|
||||||
@ -1028,12 +976,6 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currency: this.request.user.Settings.settings.baseCurrency,
|
|
||||||
currentRateService: this.currentRateService,
|
|
||||||
orders: portfolioOrders
|
|
||||||
});
|
|
||||||
|
|
||||||
if (transactionPoints?.length <= 0) {
|
if (transactionPoints?.length <= 0) {
|
||||||
return {
|
return {
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
@ -1041,6 +983,12 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: this.request.user.Settings.settings.baseCurrency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
@ -1126,6 +1074,31 @@ export class PortfolioService {
|
|||||||
const user = await this.userService.user({ id: userId });
|
const user = await this.userService.user({ id: userId });
|
||||||
const userCurrency = this.getUserCurrency(user);
|
const userCurrency = this.getUserCurrency(user);
|
||||||
|
|
||||||
|
const accountBalances = await this.accountBalanceService.getAccountBalances(
|
||||||
|
{ filters, user, withExcludedAccounts }
|
||||||
|
);
|
||||||
|
|
||||||
|
let accountBalanceItems: HistoricalDataItem[] = Object.values(
|
||||||
|
// Reduce the array to a map with unique dates as keys
|
||||||
|
accountBalances.balances.reduce(
|
||||||
|
(
|
||||||
|
map: { [date: string]: HistoricalDataItem },
|
||||||
|
{ date, valueInBaseCurrency }
|
||||||
|
) => {
|
||||||
|
const formattedDate = format(date, DATE_FORMAT);
|
||||||
|
|
||||||
|
// Store the item in the map, overwriting if the date already exists
|
||||||
|
map[formattedDate] = {
|
||||||
|
date: formattedDate,
|
||||||
|
value: valueInBaseCurrency
|
||||||
|
};
|
||||||
|
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
filters,
|
filters,
|
||||||
@ -1139,7 +1112,7 @@ export class PortfolioService {
|
|||||||
orders: portfolioOrders
|
orders: portfolioOrders
|
||||||
});
|
});
|
||||||
|
|
||||||
if (transactionPoints?.length <= 0) {
|
if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) {
|
||||||
return {
|
return {
|
||||||
chart: [],
|
chart: [],
|
||||||
firstOrderDate: undefined,
|
firstOrderDate: undefined,
|
||||||
@ -1149,6 +1122,7 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent: 0,
|
currentGrossPerformancePercent: 0,
|
||||||
currentNetPerformance: 0,
|
currentNetPerformance: 0,
|
||||||
currentNetPerformancePercent: 0,
|
currentNetPerformancePercent: 0,
|
||||||
|
currentNetWorth: 0,
|
||||||
currentValue: 0,
|
currentValue: 0,
|
||||||
totalInvestment: 0
|
totalInvestment: 0
|
||||||
}
|
}
|
||||||
@ -1157,7 +1131,15 @@ export class PortfolioService {
|
|||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = min(
|
||||||
|
[
|
||||||
|
parseDate(accountBalanceItems[0]?.date),
|
||||||
|
parseDate(transactionPoints[0]?.date)
|
||||||
|
].filter((date) => {
|
||||||
|
return isValid(date);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||||
const {
|
const {
|
||||||
currentValue,
|
currentValue,
|
||||||
@ -1175,17 +1157,17 @@ export class PortfolioService {
|
|||||||
let currentNetPerformance = netPerformance;
|
let currentNetPerformance = netPerformance;
|
||||||
let currentNetPerformancePercent = netPerformancePercentage;
|
let currentNetPerformancePercent = netPerformancePercentage;
|
||||||
|
|
||||||
const historicalDataContainer = await this.getChart({
|
const { items } = await this.getChart({
|
||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
|
||||||
impersonationId,
|
impersonationId,
|
||||||
|
portfolioOrders,
|
||||||
|
transactionPoints,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId
|
||||||
withExcludedAccounts
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
const itemOfToday = items.find(({ date }) => {
|
||||||
return item.date === format(new Date(), DATE_FORMAT);
|
return date === format(new Date(), DATE_FORMAT);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (itemOfToday) {
|
if (itemOfToday) {
|
||||||
@ -1195,34 +1177,42 @@ export class PortfolioService {
|
|||||||
).div(100);
|
).div(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
|
||||||
|
return !isBefore(parseDate(date), startDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
|
||||||
|
return date === format(new Date(), DATE_FORMAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountBalanceItemOfToday) {
|
||||||
|
accountBalanceItems.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
value: last(accountBalanceItems)?.value ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
|
||||||
|
accountBalanceItems,
|
||||||
|
items
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
|
||||||
|
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
chart: historicalDataContainer.items.map(
|
chart: mergedHistoricalDataItems,
|
||||||
({
|
firstOrderDate: parseDate(items[0]?.date),
|
||||||
date,
|
|
||||||
netPerformance: netPerformanceOfItem,
|
|
||||||
netPerformanceInPercentage,
|
|
||||||
totalInvestment: totalInvestmentOfItem,
|
|
||||||
value
|
|
||||||
}) => {
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
netPerformanceInPercentage,
|
|
||||||
value,
|
|
||||||
netPerformance: netPerformanceOfItem,
|
|
||||||
totalInvestment: totalInvestmentOfItem
|
|
||||||
};
|
|
||||||
}
|
|
||||||
),
|
|
||||||
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
|
|
||||||
performance: {
|
performance: {
|
||||||
currentValue: currentValue.toNumber(),
|
currentNetWorth,
|
||||||
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
||||||
currentGrossPerformancePercent:
|
currentGrossPerformancePercent:
|
||||||
currentGrossPerformancePercent.toNumber(),
|
currentGrossPerformancePercent.toNumber(),
|
||||||
currentNetPerformance: currentNetPerformance.toNumber(),
|
currentNetPerformance: currentNetPerformance.toNumber(),
|
||||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
|
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
|
||||||
|
currentValue: currentValue.toNumber(),
|
||||||
totalInvestment: totalInvestment.toNumber()
|
totalInvestment: totalInvestment.toNumber()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1376,6 +1366,62 @@ export class PortfolioService {
|
|||||||
return cashPositions;
|
return cashPositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getChart({
|
||||||
|
dateRange = 'max',
|
||||||
|
impersonationId,
|
||||||
|
portfolioOrders,
|
||||||
|
transactionPoints,
|
||||||
|
userCurrency,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
impersonationId: string;
|
||||||
|
portfolioOrders: PortfolioOrder[];
|
||||||
|
transactionPoints: TransactionPoint[];
|
||||||
|
userCurrency: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<HistoricalDataContainer> {
|
||||||
|
if (transactionPoints.length === 0) {
|
||||||
|
return {
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
userId = await this.getUserId(impersonationId, userId);
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: userCurrency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
|
const endDate = new Date();
|
||||||
|
|
||||||
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
|
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||||
|
|
||||||
|
const daysInMarket = differenceInDays(new Date(), startDate);
|
||||||
|
const step = Math.round(
|
||||||
|
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = await portfolioCalculator.getChartData(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
step
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private getDividendsByGroup({
|
private getDividendsByGroup({
|
||||||
dividends,
|
dividends,
|
||||||
groupBy
|
groupBy
|
||||||
@ -1593,18 +1639,18 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const { activities } = await this.orderService.getOrders({
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const excludedActivities = (
|
let { activities: excludedActivities } = await this.orderService.getOrders({
|
||||||
await this.orderService.getOrders({
|
userCurrency,
|
||||||
userCurrency,
|
userId,
|
||||||
userId,
|
withExcludedAccounts: true
|
||||||
withExcludedAccounts: true
|
});
|
||||||
})
|
|
||||||
).filter(({ Account: account }) => {
|
excludedActivities = excludedActivities.filter(({ Account: account }) => {
|
||||||
return account?.isExcluded ?? false;
|
return account?.isExcluded ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1784,7 +1830,7 @@ export class PortfolioService {
|
|||||||
const userCurrency =
|
const userCurrency =
|
||||||
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
|
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
|
||||||
|
|
||||||
const orders = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts,
|
includeDrafts,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -1793,11 +1839,11 @@ export class PortfolioService {
|
|||||||
types: ['BUY', 'SELL']
|
types: ['BUY', 'SELL']
|
||||||
});
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (count <= 0) {
|
||||||
return { transactionPoints: [], orders: [], portfolioOrders: [] };
|
return { transactionPoints: [], orders: [], portfolioOrders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
|
||||||
currency: order.SymbolProfile.currency,
|
currency: order.SymbolProfile.currency,
|
||||||
dataSource: order.SymbolProfile.dataSource,
|
dataSource: order.SymbolProfile.dataSource,
|
||||||
date: format(order.date, DATE_FORMAT),
|
date: format(order.date, DATE_FORMAT),
|
||||||
@ -1831,8 +1877,8 @@ export class PortfolioService {
|
|||||||
portfolioCalculator.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orders,
|
|
||||||
portfolioOrders,
|
portfolioOrders,
|
||||||
|
orders: activities,
|
||||||
transactionPoints: portfolioCalculator.getTransactionPoints()
|
transactionPoints: portfolioCalculator.getTransactionPoints()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1867,13 +1913,14 @@ export class PortfolioService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
|
const { activities: ordersOfTypeItemOrLiability } =
|
||||||
filters,
|
await this.orderService.getOrders({
|
||||||
userCurrency,
|
filters,
|
||||||
userId,
|
userCurrency,
|
||||||
withExcludedAccounts,
|
userId,
|
||||||
types: ['ITEM', 'LIABILITY']
|
withExcludedAccounts,
|
||||||
});
|
types: ['ITEM', 'LIABILITY']
|
||||||
|
});
|
||||||
|
|
||||||
const accounts: PortfolioDetails['accounts'] = {};
|
const accounts: PortfolioDetails['accounts'] = {};
|
||||||
const platforms: PortfolioDetails['platforms'] = {};
|
const platforms: PortfolioDetails['platforms'] = {};
|
||||||
@ -1892,9 +1939,13 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const accountIds = uniq(
|
const accountIds = uniq(
|
||||||
orders.map(({ accountId }) => {
|
orders
|
||||||
return accountId;
|
.filter(({ accountId }) => {
|
||||||
})
|
return accountId;
|
||||||
|
})
|
||||||
|
.map(({ accountId }) => {
|
||||||
|
return accountId;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
currentAccounts = await this.accountService.accounts({
|
currentAccounts = await this.accountService.accounts({
|
||||||
@ -1995,4 +2046,44 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return { accounts, platforms };
|
return { accounts, platforms };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mergeHistoricalDataItems(
|
||||||
|
accountBalanceItems: HistoricalDataItem[],
|
||||||
|
performanceChartItems: HistoricalDataItem[]
|
||||||
|
): HistoricalDataItem[] {
|
||||||
|
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
|
||||||
|
let latestAccountBalance = 0;
|
||||||
|
|
||||||
|
for (const item of accountBalanceItems.concat(performanceChartItems)) {
|
||||||
|
const isAccountBalanceItem = accountBalanceItems.includes(item);
|
||||||
|
|
||||||
|
const totalAccountBalance = isAccountBalanceItem
|
||||||
|
? item.value
|
||||||
|
: latestAccountBalance;
|
||||||
|
|
||||||
|
if (isAccountBalanceItem && performanceChartItems.length > 0) {
|
||||||
|
latestAccountBalance = item.value;
|
||||||
|
} else {
|
||||||
|
historicalDataItemsMap[item.date] = {
|
||||||
|
...item,
|
||||||
|
totalAccountBalance,
|
||||||
|
netWorth:
|
||||||
|
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to an array and sort by date in ascending order
|
||||||
|
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
|
||||||
|
(date) => {
|
||||||
|
return historicalDataItemsMap[date];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
historicalDataItems.sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return historicalDataItems;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,14 +111,14 @@ export class SubscriptionService {
|
|||||||
aSubscriptions: Subscription[]
|
aSubscriptions: Subscription[]
|
||||||
): UserWithSettings['subscription'] {
|
): UserWithSettings['subscription'] {
|
||||||
if (aSubscriptions.length > 0) {
|
if (aSubscriptions.length > 0) {
|
||||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
const { expiresAt, price } = aSubscriptions.reduce((a, b) => {
|
||||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expiresAt: latestSubscription.expiresAt,
|
expiresAt,
|
||||||
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
|
offer: price ? 'renewal' : 'default',
|
||||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
type: isBefore(new Date(), expiresAt)
|
||||||
? SubscriptionType.Premium
|
? SubscriptionType.Premium
|
||||||
: SubscriptionType.Basic
|
: SubscriptionType.Basic
|
||||||
};
|
};
|
||||||
|
@ -40,7 +40,12 @@ export class SymbolService {
|
|||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: subDays(new Date(), days) },
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
symbols: [dataGatheringItem.symbol]
|
uniqueAssets: [
|
||||||
|
{
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||||
|
@ -7,9 +7,14 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
locale
|
locale
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
User as IUser,
|
||||||
|
SystemMessage,
|
||||||
|
UserSettings
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
@ -48,6 +53,17 @@ export class UserService {
|
|||||||
orderBy: { alias: 'asc' },
|
orderBy: { alias: 'asc' },
|
||||||
where: { GranteeUser: { id } }
|
where: { GranteeUser: { id } }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let systemMessage: SystemMessage;
|
||||||
|
|
||||||
|
const systemMessageProperty = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
|
)) as SystemMessage;
|
||||||
|
|
||||||
|
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
|
||||||
|
systemMessage = systemMessageProperty;
|
||||||
|
}
|
||||||
|
|
||||||
let tags = await this.tagService.getByUser(id);
|
let tags = await this.tagService.getByUser(id);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -61,6 +77,7 @@ export class UserService {
|
|||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
|
systemMessage,
|
||||||
tags,
|
tags,
|
||||||
access: access.map((accessItem) => {
|
access: access.map((accessItem) => {
|
||||||
return {
|
return {
|
||||||
@ -110,7 +127,9 @@ export class UserService {
|
|||||||
updatedAt
|
updatedAt
|
||||||
} = await this.prismaService.user.findUnique({
|
} = await this.prismaService.user.findUnique({
|
||||||
include: {
|
include: {
|
||||||
Account: true,
|
Account: {
|
||||||
|
include: { Platform: true }
|
||||||
|
},
|
||||||
Analytics: true,
|
Analytics: true,
|
||||||
Settings: true,
|
Settings: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
@ -179,16 +198,18 @@ export class UserService {
|
|||||||
new Date(),
|
new Date(),
|
||||||
user.createdAt
|
user.createdAt
|
||||||
);
|
);
|
||||||
let frequency = 20;
|
let frequency = 15;
|
||||||
|
|
||||||
if (daysSinceRegistration > 180) {
|
if (daysSinceRegistration > 365) {
|
||||||
|
frequency = 2;
|
||||||
|
} else if (daysSinceRegistration > 180) {
|
||||||
frequency = 3;
|
frequency = 3;
|
||||||
} else if (daysSinceRegistration > 60) {
|
} else if (daysSinceRegistration > 60) {
|
||||||
frequency = 5;
|
frequency = 5;
|
||||||
} else if (daysSinceRegistration > 30) {
|
} else if (daysSinceRegistration > 30) {
|
||||||
frequency = 10;
|
frequency = 8;
|
||||||
} else if (daysSinceRegistration > 15) {
|
} else if (daysSinceRegistration > 15) {
|
||||||
frequency = 15;
|
frequency = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Analytics?.activityCount % frequency === 1) {
|
if (Analytics?.activityCount % frequency === 1) {
|
||||||
@ -233,8 +254,8 @@ export class UserService {
|
|||||||
currentPermissions.push(permissions.impersonateAllUsers);
|
currentPermissions.push(permissions.impersonateAllUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Account = sortBy(user.Account, (account) => {
|
user.Account = sortBy(user.Account, ({ name }) => {
|
||||||
return account.name;
|
return name.toLowerCase();
|
||||||
});
|
});
|
||||||
user.permissions = currentPermissions.sort();
|
user.permissions = currentPermissions.sort();
|
||||||
|
|
||||||
|
@ -54,10 +54,22 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-basil-finance</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -70,10 +82,18 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-de.fi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -82,6 +102,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -90,6 +114,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -114,6 +142,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-magnifi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -122,6 +154,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monarch-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -146,6 +182,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -174,18 +214,34 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -290,6 +346,14 @@
|
|||||||
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/11/black-week-2023</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/faq</loc>
|
<loc>https://ghostfol.io/en/faq</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -324,10 +388,22 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -340,10 +416,18 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -352,6 +436,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -360,6 +448,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -384,6 +476,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -392,6 +488,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -416,6 +516,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -444,18 +548,34 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/es</loc>
|
<loc>https://ghostfol.io/es</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -622,10 +742,22 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allvue-systems</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-basil-finance</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -638,10 +770,18 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-de.fi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -650,6 +790,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -658,6 +802,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -682,6 +830,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-magnifi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -690,6 +842,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monarch-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -714,6 +870,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -742,18 +902,34 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-whal</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-ynab</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl</loc>
|
<loc>https://ghostfol.io/nl</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -766,10 +942,22 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allvue-systems</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-basil-finance</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -782,10 +970,18 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-de.fi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -794,6 +990,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -802,6 +1002,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -826,6 +1030,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-magnifi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -834,6 +1042,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monarch-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -858,6 +1070,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -886,18 +1102,34 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-whal</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-ynab</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
|
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -944,6 +1176,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pl</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pt</loc>
|
<loc>https://ghostfol.io/pt</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
6
apps/api/src/decorators/has-permission.decorator.ts
Normal file
6
apps/api/src/decorators/has-permission.decorator.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
export const HAS_PERMISSION_KEY = 'has_permission';
|
||||||
|
|
||||||
|
export function HasPermission(permission: string) {
|
||||||
|
return SetMetadata(HAS_PERMISSION_KEY, permission);
|
||||||
|
}
|
55
apps/api/src/guards/has-permission.guard.spec.ts
Normal file
55
apps/api/src/guards/has-permission.guard.spec.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { HasPermissionGuard } from './has-permission.guard';
|
||||||
|
|
||||||
|
describe('HasPermissionGuard', () => {
|
||||||
|
let guard: HasPermissionGuard;
|
||||||
|
let reflector: Reflector;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [HasPermissionGuard, Reflector]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
|
||||||
|
reflector = module.get<Reflector>(Reflector);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupReflectorSpy(returnValue: string) {
|
||||||
|
jest.spyOn(reflector, 'get').mockReturnValue(returnValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockExecutionContext(permissions: string[]) {
|
||||||
|
return new ExecutionContextHost([
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
permissions // Set user permissions based on the argument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should deny access if the user does not have any permission', () => {
|
||||||
|
setupReflectorSpy('required-permission');
|
||||||
|
const noPermissions = createMockExecutionContext([]);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(noPermissions)).toThrow(HttpException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access if the user has the wrong permission', () => {
|
||||||
|
setupReflectorSpy('required-permission');
|
||||||
|
const wrongPermission = createMockExecutionContext(['wrong-permission']);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access if the user has the required permission', () => {
|
||||||
|
setupReflectorSpy('required-permission');
|
||||||
|
const rightPermission = createMockExecutionContext(['required-permission']);
|
||||||
|
|
||||||
|
expect(guard.canActivate(rightPermission)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
37
apps/api/src/guards/has-permission.guard.ts
Normal file
37
apps/api/src/guards/has-permission.guard.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { hasPermission } from '@ghostfolio/common/permissions';
|
||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
Injectable
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HasPermissionGuard implements CanActivate {
|
||||||
|
public constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
public canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredPermission = this.reflector.get<string>(
|
||||||
|
HAS_PERMISSION_KEY,
|
||||||
|
context.getHandler()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredPermission) {
|
||||||
|
return true; // No specific permissions required
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
if (!user || !hasPermission(user.permissions, requiredPermission)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -75,6 +75,14 @@ const locales = {
|
|||||||
'/en/blog/2023/09/hacktoberfest-2023': {
|
'/en/blog/2023/09/hacktoberfest-2023': {
|
||||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||||
title: `Hacktoberfest 2023 - ${title}`
|
title: `Hacktoberfest 2023 - ${title}`
|
||||||
|
},
|
||||||
|
'/en/blog/2023/11/black-week-2023': {
|
||||||
|
featureGraphicPath: 'assets/images/blog/black-week-2023.jpg',
|
||||||
|
title: `Black Week 2023 - ${title}`
|
||||||
|
},
|
||||||
|
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
|
||||||
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||||
|
title: `Hacktoberfest 2023 Debriefing - ${title}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,6 +91,9 @@ const isFileRequest = (filename: string) => {
|
|||||||
return true;
|
return true;
|
||||||
} else if (
|
} else if (
|
||||||
filename.includes('auth/ey') ||
|
filename.includes('auth/ey') ||
|
||||||
|
filename.includes(
|
||||||
|
'personal-finance-tools/open-source-alternative-to-de.fi'
|
||||||
|
) ||
|
||||||
filename.includes(
|
filename.includes(
|
||||||
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||||
)
|
)
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
exports: [AccountBalanceService],
|
|
||||||
imports: [PrismaModule],
|
|
||||||
providers: [AccountBalanceService]
|
|
||||||
})
|
|
||||||
export class AccountBalanceModule {}
|
|
@ -1,42 +0,0 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
||||||
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AccountBalanceService {
|
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
|
||||||
|
|
||||||
public async createAccountBalance(
|
|
||||||
data: Prisma.AccountBalanceCreateInput
|
|
||||||
): Promise<AccountBalance> {
|
|
||||||
return this.prismaService.accountBalance.create({
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAccountBalances({
|
|
||||||
accountId,
|
|
||||||
userId
|
|
||||||
}: {
|
|
||||||
accountId: string;
|
|
||||||
userId: string;
|
|
||||||
}): Promise<AccountBalancesResponse> {
|
|
||||||
const balances = await this.prismaService.accountBalance.findMany({
|
|
||||||
orderBy: {
|
|
||||||
date: 'asc'
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
date: true,
|
|
||||||
id: true,
|
|
||||||
value: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
accountId,
|
|
||||||
userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { balances };
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,6 +5,7 @@ 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';
|
||||||
@ -106,8 +107,10 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
return {};
|
return {};
|
||||||
|
@ -56,7 +56,13 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
|
|
||||||
response.name = name;
|
response.name = name;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'CoinGeckoService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'CoinGeckoService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@ -135,8 +141,10 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
@ -150,9 +158,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, requestTimeout);
|
||||||
|
|
||||||
const response = await got(
|
const quotes = await got(
|
||||||
`${this.URL}/simple/price?ids=${symbols.join(
|
`${this.URL}/simple/price?ids=${symbols.join(
|
||||||
','
|
','
|
||||||
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
||||||
@ -162,19 +170,23 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
for (const symbol in response) {
|
for (const symbol in quotes) {
|
||||||
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
response[symbol] = {
|
||||||
response[symbol] = {
|
currency: DEFAULT_CURRENCY,
|
||||||
currency: DEFAULT_CURRENCY,
|
dataProviderInfo: this.getDataProviderInfo(),
|
||||||
dataProviderInfo: this.getDataProviderInfo(),
|
dataSource: DataSource.COINGECKO,
|
||||||
dataSource: DataSource.COINGECKO,
|
marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()],
|
||||||
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
|
marketState: 'open'
|
||||||
marketState: 'open'
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'CoinGeckoService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'CoinGeckoService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@ -216,7 +228,13 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'CoinGeckoService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'CoinGeckoService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
|
@ -2,6 +2,7 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/in
|
|||||||
import { HttpException, Inject, Injectable } from '@nestjs/common';
|
import { HttpException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataEnhancerService {
|
export class DataEnhancerService {
|
||||||
@ -24,6 +25,7 @@ export class DataEnhancerService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const assetProfile = await dataEnhancer.enhance({
|
const assetProfile = await dataEnhancer.enhance({
|
||||||
|
requestTimeout: ms('30 seconds'),
|
||||||
response: {
|
response: {
|
||||||
assetClass: 'EQUITY',
|
assetClass: 'EQUITY',
|
||||||
assetSubClass: 'ETF'
|
assetSubClass: 'ETF'
|
||||||
|
@ -15,9 +15,11 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async enhance({
|
public async enhance({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
response: Partial<SymbolProfile>;
|
response: Partial<SymbolProfile>;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<Partial<SymbolProfile>> {
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
@ -45,7 +47,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, requestTimeout);
|
||||||
|
|
||||||
const mappings = await got
|
const mappings = await got
|
||||||
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
||||||
|
@ -13,6 +13,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
private static countriesMapping = {
|
private static countriesMapping = {
|
||||||
'Russian Federation': 'Russia'
|
'Russian Federation': 'Russia'
|
||||||
};
|
};
|
||||||
|
private static holdingsWeightTreshold = 0.85;
|
||||||
private static sectorsMapping = {
|
private static sectorsMapping = {
|
||||||
'Consumer Discretionary': 'Consumer Cyclical',
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
'Consumer Defensive': 'Consumer Staples',
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
@ -21,9 +22,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public async enhance({
|
public async enhance({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
response: Partial<SymbolProfile>;
|
response: Partial<SymbolProfile>;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<Partial<SymbolProfile>> {
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
@ -37,7 +40,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, requestTimeout);
|
||||||
|
|
||||||
const profile = await got(
|
const profile = await got(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
|
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
|
||||||
@ -111,7 +114,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (holdings?.weight < 0.95) {
|
if (
|
||||||
|
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold
|
||||||
|
) {
|
||||||
// Skip if data is inaccurate
|
// Skip if data is inaccurate
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
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 { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import {
|
||||||
|
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 {
|
||||||
@ -10,6 +14,7 @@ import {
|
|||||||
Prisma,
|
Prisma,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { isISIN } from 'class-validator';
|
||||||
import { countries } from 'countries-list';
|
import { countries } from 'countries-list';
|
||||||
import yahooFinance from 'yahoo-finance2';
|
import yahooFinance from 'yahoo-finance2';
|
||||||
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||||
@ -71,9 +76,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async enhance({
|
public async enhance({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
response: Partial<SymbolProfile>;
|
response: Partial<SymbolProfile>;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<Partial<SymbolProfile>> {
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
@ -156,7 +163,20 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
const response: Partial<SymbolProfile> = {};
|
const response: Partial<SymbolProfile> = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
let symbol = aSymbol;
|
||||||
|
|
||||||
|
if (isISIN(symbol)) {
|
||||||
|
try {
|
||||||
|
const { quotes } = await yahooFinance.search(symbol);
|
||||||
|
|
||||||
|
if (quotes.length === 1) {
|
||||||
|
symbol = quotes[0].symbol;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
symbol = this.convertToYahooFinanceSymbol(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||||
});
|
});
|
||||||
@ -176,7 +196,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
shortName: assetProfile.price.shortName,
|
shortName: assetProfile.price.shortName,
|
||||||
symbol: assetProfile.price.symbol
|
symbol: assetProfile.price.symbol
|
||||||
});
|
});
|
||||||
response.symbol = aSymbol;
|
response.symbol = assetProfile.price.symbol;
|
||||||
|
|
||||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
|
@ -17,6 +17,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||||
import { format, isValid } from 'date-fns';
|
import { format, isValid } from 'date-fns';
|
||||||
import { groupBy, isEmpty, isNumber } from 'lodash';
|
import { groupBy, isEmpty, isNumber } from 'lodash';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService {
|
export class DataProviderService {
|
||||||
@ -52,6 +53,7 @@ export class DataProviderService {
|
|||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
requestTimeout: ms('30 seconds'),
|
||||||
useCache: false
|
useCache: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -236,9 +238,11 @@ export class DataProviderService {
|
|||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
items,
|
items,
|
||||||
|
requestTimeout,
|
||||||
useCache = true
|
useCache = true
|
||||||
}: {
|
}: {
|
||||||
items: UniqueAsset[];
|
items: UniqueAsset[];
|
||||||
|
requestTimeout?: number;
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
@ -312,7 +316,7 @@ export class DataProviderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const promise = Promise.resolve(
|
const promise = Promise.resolve(
|
||||||
dataProvider.getQuotes({ symbols: symbolsChunk })
|
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
|
||||||
);
|
);
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
@ -342,7 +346,7 @@ export class DataProviderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.marketDataService.updateMany({
|
await this.marketDataService.updateMany({
|
||||||
data: Object.keys(response)
|
data: Object.keys(response)
|
||||||
.filter((symbol) => {
|
.filter((symbol) => {
|
||||||
return (
|
return (
|
||||||
|
@ -132,8 +132,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
let response: { [symbol: string]: IDataProviderResponse } = {};
|
let response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
@ -151,7 +153,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, requestTimeout);
|
||||||
|
|
||||||
const realTimeResponse = await got(
|
const realTimeResponse = await got(
|
||||||
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
|
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
|
||||||
@ -227,7 +229,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'EodHistoricalDataService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'EodHistoricalDataService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@ -380,7 +388,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'EodHistoricalDataService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'EodHistoricalDataService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchResult;
|
return searchResult;
|
||||||
|
@ -114,8 +114,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
@ -129,9 +131,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, requestTimeout);
|
||||||
|
|
||||||
const response = await got(
|
const quotes = await got(
|
||||||
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
|
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
|
||||||
{
|
{
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -139,7 +141,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
for (const { price, symbol } of response) {
|
for (const { price, symbol } of quotes) {
|
||||||
response[symbol] = {
|
response[symbol] = {
|
||||||
currency: DEFAULT_CURRENCY,
|
currency: DEFAULT_CURRENCY,
|
||||||
dataProviderInfo: this.getDataProviderInfo(),
|
dataProviderInfo: this.getDataProviderInfo(),
|
||||||
@ -149,7 +151,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'FinancialModelingPrepService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'FinancialModelingPrepService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@ -194,7 +202,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'FinancialModelingPrepService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'FinancialModelingPrepService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
|
@ -7,6 +7,7 @@ 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';
|
||||||
@ -100,8 +101,10 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
@ -2,9 +2,11 @@ import { SymbolProfile } from '@prisma/client';
|
|||||||
|
|
||||||
export interface DataEnhancerInterface {
|
export interface DataEnhancerInterface {
|
||||||
enhance({
|
enhance({
|
||||||
|
requestTimeout,
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
response: Partial<SymbolProfile>;
|
response: Partial<SymbolProfile>;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<Partial<SymbolProfile>>;
|
}): Promise<Partial<SymbolProfile>>;
|
||||||
|
@ -37,8 +37,10 @@ export interface DataProviderInterface {
|
|||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
getQuotes({
|
getQuotes({
|
||||||
|
requestTimeout,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||||
|
|
||||||
|
@ -134,8 +134,10 @@ export class ManualService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
@ -88,8 +88,10 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
if (symbols.length <= 0) {
|
if (symbols.length <= 0) {
|
||||||
@ -161,7 +163,13 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'RapidApiService');
|
let message = error;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(message, 'RapidApiService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,10 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
import {
|
||||||
|
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';
|
||||||
@ -30,7 +33,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
const { assetClass, assetSubClass, currency, name } =
|
const { assetClass, assetSubClass, currency, name, symbol } =
|
||||||
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -38,8 +41,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
currency,
|
currency,
|
||||||
name,
|
name,
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,8 +160,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||||
symbols
|
symbols
|
||||||
}: {
|
}: {
|
||||||
|
requestTimeout?: number;
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
@ -95,6 +95,30 @@ export class ExchangeRateDataService {
|
|||||||
const [currency1, currency2] = symbol.match(/.{1,3}/g);
|
const [currency1, currency2] = symbol.match(/.{1,3}/g);
|
||||||
const [date] = Object.keys(result[symbol]);
|
const [date] = Object.keys(result[symbol]);
|
||||||
|
|
||||||
|
// Add derived currencies
|
||||||
|
if (currency2 === 'GBP') {
|
||||||
|
resultExtended[`${currency1}GBp`] = {
|
||||||
|
[date]: {
|
||||||
|
marketPrice:
|
||||||
|
result[`${currency1}${currency2}`][date].marketPrice * 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else if (currency2 === 'ILS') {
|
||||||
|
resultExtended[`${currency1}ILA`] = {
|
||||||
|
[date]: {
|
||||||
|
marketPrice:
|
||||||
|
result[`${currency1}${currency2}`][date].marketPrice * 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else if (currency2 === 'ZAR') {
|
||||||
|
resultExtended[`${currency1}ZAc`] = {
|
||||||
|
[date]: {
|
||||||
|
marketPrice:
|
||||||
|
result[`${currency1}${currency2}`][date].marketPrice * 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate the opposite direction
|
// Calculate the opposite direction
|
||||||
resultExtended[`${currency2}${currency1}`] = {
|
resultExtended[`${currency2}${currency1}`] = {
|
||||||
[date]: {
|
[date]: {
|
||||||
|
@ -59,12 +59,12 @@ export class MarketDataService {
|
|||||||
|
|
||||||
public async getRange({
|
public async getRange({
|
||||||
dateQuery,
|
dateQuery,
|
||||||
symbols
|
uniqueAssets
|
||||||
}: {
|
}: {
|
||||||
dateQuery: DateQuery;
|
dateQuery: DateQuery;
|
||||||
symbols: string[];
|
uniqueAssets: UniqueAsset[];
|
||||||
}): Promise<MarketData[]> {
|
}): Promise<MarketData[]> {
|
||||||
return await this.prismaService.marketData.findMany({
|
return this.prismaService.marketData.findMany({
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
@ -74,24 +74,33 @@ export class MarketDataService {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
where: {
|
where: {
|
||||||
|
dataSource: {
|
||||||
|
in: uniqueAssets.map(({ dataSource }) => {
|
||||||
|
return dataSource;
|
||||||
|
})
|
||||||
|
},
|
||||||
date: dateQuery,
|
date: dateQuery,
|
||||||
symbol: {
|
symbol: {
|
||||||
in: symbols
|
in: uniqueAssets.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async marketDataItems(params: {
|
public async marketDataItems(params: {
|
||||||
|
select?: Prisma.MarketDataSelectScalar;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
take?: number;
|
take?: number;
|
||||||
cursor?: Prisma.MarketDataWhereUniqueInput;
|
cursor?: Prisma.MarketDataWhereUniqueInput;
|
||||||
where?: Prisma.MarketDataWhereInput;
|
where?: Prisma.MarketDataWhereInput;
|
||||||
orderBy?: Prisma.MarketDataOrderByWithRelationInput;
|
orderBy?: Prisma.MarketDataOrderByWithRelationInput;
|
||||||
}): Promise<MarketData[]> {
|
}): Promise<MarketData[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { select, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prismaService.marketData.findMany({
|
return this.prismaService.marketData.findMany({
|
||||||
|
select,
|
||||||
cursor,
|
cursor,
|
||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
|
@ -57,7 +57,7 @@ export class TwitterBotService {
|
|||||||
symbolItem.marketPrice
|
symbolItem.marketPrice
|
||||||
}/100)`;
|
}/100)`;
|
||||||
|
|
||||||
const benchmarkListing = await this.getBenchmarkListing(3);
|
const benchmarkListing = await this.getBenchmarkListing();
|
||||||
|
|
||||||
if (benchmarkListing?.length > 1) {
|
if (benchmarkListing?.length > 1) {
|
||||||
status += '\n\n';
|
status += '\n\n';
|
||||||
@ -78,29 +78,22 @@ export class TwitterBotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getBenchmarkListing(aMax: number) {
|
private async getBenchmarkListing() {
|
||||||
const benchmarks = await this.benchmarkService.getBenchmarks({
|
const benchmarks = await this.benchmarkService.getBenchmarks({
|
||||||
|
enableSharing: true,
|
||||||
useCache: false
|
useCache: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const benchmarkListing: string[] = [];
|
return benchmarks
|
||||||
|
.map(({ marketCondition, name, performances }) => {
|
||||||
for (const [index, benchmark] of benchmarks.entries()) {
|
return `${name} ${(
|
||||||
if (index > aMax - 1) {
|
performances.allTimeHigh.performancePercent * 100
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
benchmarkListing.push(
|
|
||||||
`${benchmark.name} ${(
|
|
||||||
benchmark.performances.allTimeHigh.performancePercent * 100
|
|
||||||
).toFixed(1)}%${
|
).toFixed(1)}%${
|
||||||
benchmark.marketCondition !== 'NEUTRAL_MARKET'
|
marketCondition !== 'NEUTRAL_MARKET'
|
||||||
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
|
? ' ' + resolveMarketCondition(marketCondition).emoji
|
||||||
: ''
|
: ''
|
||||||
}`
|
}`;
|
||||||
);
|
})
|
||||||
}
|
.join('\n');
|
||||||
|
|
||||||
return benchmarkListing.join('\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
});
|
@ -60,6 +60,10 @@
|
|||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"localize": ["nl"]
|
"localize": ["nl"]
|
||||||
},
|
},
|
||||||
|
"development-pl": {
|
||||||
|
"baseHref": "/pl/",
|
||||||
|
"localize": ["pl"]
|
||||||
|
},
|
||||||
"development-pt": {
|
"development-pt": {
|
||||||
"baseHref": "/pt/",
|
"baseHref": "/pt/",
|
||||||
"localize": ["pt"]
|
"localize": ["pt"]
|
||||||
@ -146,45 +150,48 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/angular:webpack-dev-server",
|
"executor": "@nx/angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "client:build",
|
"proxyConfig": "apps/client/proxy.conf.json",
|
||||||
"proxyConfig": "apps/client/proxy.conf.json"
|
"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": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "client:build",
|
"buildTarget": "client:build",
|
||||||
"includeContext": true,
|
"includeContext": true,
|
||||||
"outputPath": "src/locales",
|
"outputPath": "src/locales",
|
||||||
"targetFiles": [
|
"targetFiles": [
|
||||||
@ -193,6 +200,7 @@
|
|||||||
"messages.fr.xlf",
|
"messages.fr.xlf",
|
||||||
"messages.it.xlf",
|
"messages.it.xlf",
|
||||||
"messages.nl.xlf",
|
"messages.nl.xlf",
|
||||||
|
"messages.pl.xlf",
|
||||||
"messages.pt.xlf",
|
"messages.pt.xlf",
|
||||||
"messages.tr.xlf"
|
"messages.tr.xlf"
|
||||||
]
|
]
|
||||||
@ -207,8 +215,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@nx/jest:jest",
|
"executor": "@nx/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/client/jest.config.ts",
|
"jestConfig": "apps/client/jest.config.ts"
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
},
|
||||||
"outputs": ["{workspaceRoot}/coverage/apps/client"]
|
"outputs": ["{workspaceRoot}/coverage/apps/client"]
|
||||||
}
|
}
|
||||||
@ -235,6 +242,10 @@
|
|||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||||
},
|
},
|
||||||
|
"pl": {
|
||||||
|
"baseHref": "/pl/",
|
||||||
|
"translation": "apps/client/src/locales/messages.pl.xlf"
|
||||||
|
},
|
||||||
"pt": {
|
"pt": {
|
||||||
"baseHref": "/pt/",
|
"baseHref": "/pt/",
|
||||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
|
||||||
import { Inject, forwardRef } from '@angular/core';
|
import { Inject, forwardRef } from '@angular/core';
|
||||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
@ -7,10 +6,9 @@ import { addYears, format, getYear, parse } from 'date-fns';
|
|||||||
export class CustomDateAdapter extends NativeDateAdapter {
|
export class CustomDateAdapter extends NativeDateAdapter {
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(MAT_DATE_LOCALE) public locale: string,
|
@Inject(MAT_DATE_LOCALE) public locale: string,
|
||||||
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
|
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
|
||||||
platform: Platform
|
|
||||||
) {
|
) {
|
||||||
super(matDateLocale, platform);
|
super(matDateLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<header>
|
<header>
|
||||||
<div
|
<div
|
||||||
*ngIf="canCreateAccount || (info?.systemMessage && user)"
|
*ngIf="canCreateAccount || user?.systemMessage"
|
||||||
class="info-message-container"
|
class="info-message-container"
|
||||||
>
|
>
|
||||||
<div class="info-message-inner-container position-fixed w-100">
|
<div class="info-message-inner-container position-fixed w-100">
|
||||||
@ -19,11 +19,11 @@
|
|||||||
</div></a
|
</div></a
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
*ngIf="!canCreateAccount && info?.systemMessage && user"
|
*ngIf="!canCreateAccount && user?.systemMessage"
|
||||||
class="cursor-pointer d-inline-block info-message text-truncate"
|
class="cursor-pointer d-inline-block info-message text-truncate"
|
||||||
(click)="onShowSystemMessage()"
|
(click)="onClickSystemMessage()"
|
||||||
>
|
>
|
||||||
{{ info.systemMessage }}
|
{{ user.systemMessage.message }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -127,8 +127,11 @@
|
|||||||
class="align-items-baseline d-flex"
|
class="align-items-baseline d-flex"
|
||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Follow Ghostfolio on Twitter"
|
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||||
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
>X (formerly Twitter)<ion-icon
|
||||||
|
class="ml-1"
|
||||||
|
name="open-outline"
|
||||||
|
></ion-icon
|
||||||
></a>
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li> </li>
|
<li> </li>
|
||||||
@ -150,6 +153,11 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||||
</li>
|
</li>
|
||||||
|
<!--
|
||||||
|
<li>
|
||||||
|
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
<li>
|
<li>
|
||||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -155,10 +155,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.hasInfoMessage =
|
this.hasInfoMessage =
|
||||||
hasPermission(
|
this.canCreateAccount || !!this.user?.systemMessage;
|
||||||
this.user?.permissions,
|
|
||||||
permissions.createUserAccount
|
|
||||||
) || !!this.info.systemMessage;
|
|
||||||
|
|
||||||
this.initializeTheme(this.user?.settings.colorScheme);
|
this.initializeTheme(this.user?.settings.colorScheme);
|
||||||
|
|
||||||
@ -166,12 +163,16 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCreateAccount() {
|
public onClickSystemMessage() {
|
||||||
this.tokenStorageService.signOut();
|
if (this.user.systemMessage.routerLink) {
|
||||||
|
this.router.navigate(this.user.systemMessage.routerLink);
|
||||||
|
} else {
|
||||||
|
alert(this.user.systemMessage.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onShowSystemMessage() {
|
public onCreateAccount() {
|
||||||
alert(this.info.systemMessage);
|
this.tokenStorageService.signOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||||
|
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
|
@ -7,11 +7,18 @@ 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';
|
||||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AccountBalancesResponse,
|
||||||
|
HistoricalDataItem,
|
||||||
|
User
|
||||||
|
} 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';
|
||||||
@ -29,15 +36,22 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
|
|||||||
styleUrls: ['./account-detail-dialog.component.scss']
|
styleUrls: ['./account-detail-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class AccountDetailDialog implements OnDestroy, OnInit {
|
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||||
|
public accountBalances: AccountBalancesResponse['balances'];
|
||||||
|
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 historicalDataItems: HistoricalDataItem[];
|
public historicalDataItems: HistoricalDataItem[];
|
||||||
|
public isLoadingActivities: boolean;
|
||||||
public isLoadingChart: boolean;
|
public isLoadingChart: boolean;
|
||||||
public name: string;
|
public name: string;
|
||||||
public orders: OrderWithAccount[];
|
|
||||||
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;
|
||||||
@ -58,14 +72,17 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccountBalance = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.deleteAccountBalance
|
||||||
|
);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.isLoadingChart = true;
|
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchAccount(this.data.accountId)
|
.fetchAccount(this.data.accountId)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -97,66 +114,49 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.dataService
|
|
||||||
.fetchActivities({
|
|
||||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ activities }) => {
|
|
||||||
this.orders = activities;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dataService
|
|
||||||
.fetchPortfolioPerformance({
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
id: this.data.accountId,
|
|
||||||
type: 'ACCOUNT'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
range: 'max',
|
|
||||||
withExcludedAccounts: true
|
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ chart }) => {
|
|
||||||
this.historicalDataItems = chart.map(
|
|
||||||
({ date, value, valueInPercentage }) => {
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
value:
|
|
||||||
this.hasImpersonationId || this.user.settings.isRestrictedView
|
|
||||||
? valueInPercentage
|
|
||||||
: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.isLoadingChart = false;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((impersonationId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!impersonationId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.fetchAccountBalances();
|
||||||
|
this.fetchActivities();
|
||||||
|
this.fetchPortfolioPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onClose() {
|
public onClose() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onExport() {
|
public onDeleteAccountBalance(aId: string) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchExport(
|
.deleteAccountBalance(aId)
|
||||||
this.orders.map((order) => {
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
return order.id;
|
.subscribe({
|
||||||
})
|
next: () => {
|
||||||
)
|
this.fetchAccountBalances();
|
||||||
|
this.fetchPortfolioPerformance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.fetchExport(activityIds)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
downloadAsFile({
|
downloadAsFile({
|
||||||
@ -172,6 +172,93 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSortChanged({ active, direction }: Sort) {
|
||||||
|
this.sortColumn = active;
|
||||||
|
this.sortDirection = direction;
|
||||||
|
|
||||||
|
this.fetchActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAccountBalances() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAccountBalances(this.data.accountId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ balances }) => {
|
||||||
|
this.accountBalances = balances;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
this.isLoadingChart = true;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioPerformance({
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: this.data.accountId,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
range: 'max',
|
||||||
|
withExcludedAccounts: true
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ chart }) => {
|
||||||
|
this.historicalDataItems = chart.map(
|
||||||
|
({ date, netWorth, netWorthInPercentage }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
value:
|
||||||
|
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||||
|
? netWorthInPercentage
|
||||||
|
: netWorth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isLoadingChart = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
></gf-investment-chart>
|
></gf-investment-chart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="mb-3 row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
i18n
|
i18n
|
||||||
@ -64,11 +64,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
<mat-tab-group
|
||||||
<div class="col mb-3">
|
animationDuration="0"
|
||||||
<div class="h5 mb-0" i18n>Activities</div>
|
[mat-stretch-tabs]="false"
|
||||||
|
[ngClass]="{ 'd-none': isLoadingActivities }"
|
||||||
|
>
|
||||||
|
<mat-tab>
|
||||||
|
<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
|
||||||
[activities]="orders"
|
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||||
|
[activities]="activities"
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="data.deviceType"
|
[deviceType]="data.deviceType"
|
||||||
[hasPermissionToCreateActivity]="false"
|
[hasPermissionToCreateActivity]="false"
|
||||||
@ -79,8 +101,18 @@
|
|||||||
[showActions]="false"
|
[showActions]="false"
|
||||||
(export)="onExport()"
|
(export)="onExport()"
|
||||||
></gf-activities-table>
|
></gf-activities-table>
|
||||||
</div>
|
</mat-tab>
|
||||||
</div>
|
<mat-tab>
|
||||||
|
<ng-template i18n mat-tab-label>Cash Balances</ng-template>
|
||||||
|
<gf-account-balances
|
||||||
|
[accountBalances]="accountBalances"
|
||||||
|
[accountId]="data.accountId"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
|
||||||
|
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
|
||||||
|
></gf-account-balances>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -2,9 +2,12 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
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 { 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 { 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';
|
||||||
@ -15,13 +18,16 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
|
|||||||
declarations: [AccountDetailDialog],
|
declarations: [AccountDetailDialog],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfAccountBalancesModule,
|
||||||
GfActivitiesTableModule,
|
GfActivitiesTableModule,
|
||||||
|
GfActivitiesTableLazyModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
GfInvestmentChartModule,
|
GfInvestmentChartModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
MatTabsModule,
|
||||||
NgxSkeletonLoaderModule
|
NgxSkeletonLoaderModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -241,7 +241,7 @@
|
|||||||
></td>
|
></td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<button
|
<button
|
||||||
|
@ -120,7 +120,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||||
import { MatSort, Sort } from '@angular/material/sort';
|
import { MatSort, Sort, SortDirection } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
@ -19,7 +19,8 @@ import { getDateFormatString } from '@ghostfolio/common/helper';
|
|||||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
import { isUUID } from 'class-validator';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
@ -83,7 +84,7 @@ export class AdminMarketDataComponent
|
|||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public displayedColumns = [
|
public displayedColumns = [
|
||||||
'symbol',
|
'nameWithSymbol',
|
||||||
'dataSource',
|
'dataSource',
|
||||||
'assetClass',
|
'assetClass',
|
||||||
'assetSubClass',
|
'assetSubClass',
|
||||||
@ -97,6 +98,7 @@ export class AdminMarketDataComponent
|
|||||||
];
|
];
|
||||||
public filters$ = new Subject<Filter[]>();
|
public filters$ = new Subject<Filter[]>();
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
|
public isUUID = isUUID;
|
||||||
public placeholder = '';
|
public placeholder = '';
|
||||||
public pageSize = DEFAULT_PAGE_SIZE;
|
public pageSize = DEFAULT_PAGE_SIZE;
|
||||||
public totalItems = 0;
|
public totalItems = 0;
|
||||||
@ -158,7 +160,7 @@ export class AdminMarketDataComponent
|
|||||||
|
|
||||||
this.loadData({
|
this.loadData({
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection: <Prisma.SortOrder>direction,
|
sortDirection: direction,
|
||||||
pageIndex: this.paginator.pageIndex
|
pageIndex: this.paginator.pageIndex
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -173,7 +175,7 @@ export class AdminMarketDataComponent
|
|||||||
this.loadData({
|
this.loadData({
|
||||||
pageIndex: page.pageIndex,
|
pageIndex: page.pageIndex,
|
||||||
sortColumn: this.sort.active,
|
sortColumn: this.sort.active,
|
||||||
sortDirection: <Prisma.SortOrder>this.sort.direction
|
sortDirection: this.sort.direction
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,7 +262,7 @@ export class AdminMarketDataComponent
|
|||||||
}: {
|
}: {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
sortColumn?: string;
|
sortColumn?: string;
|
||||||
sortDirection?: Prisma.SortOrder;
|
sortDirection?: SortDirection;
|
||||||
} = { pageIndex: 0 }
|
} = { pageIndex: 0 }
|
||||||
) {
|
) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
@ -28,6 +28,24 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="nameWithSymbol">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="symbol"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Name</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
||||||
|
<div class="text-truncate">{{ element.name }}</div>
|
||||||
|
<div *ngIf="!isUUID(element.symbol)">
|
||||||
|
<small class="text-muted">{{ element.symbol | gfSymbol }}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="dataSource">
|
<ng-container matColumnDef="dataSource">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
<ng-container i18n>Data Source</ng-container>
|
<ng-container i18n>Data Source</ng-container>
|
||||||
@ -111,7 +129,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
|
@ -6,6 +6,7 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
|||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
|
|||||||
GfActivitiesFilterModule,
|
GfActivitiesFilterModule,
|
||||||
GfAssetProfileDialogModule,
|
GfAssetProfileDialogModule,
|
||||||
GfCreateAssetProfileDialogModule,
|
GfCreateAssetProfileDialogModule,
|
||||||
|
GfSymbolModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -25,8 +26,8 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { parse as csvToJson } from 'papaparse';
|
import { parse as csvToJson } from 'papaparse';
|
||||||
import { Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AssetProfileDialogParams } from './interfaces/interfaces';
|
import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -50,6 +51,9 @@ 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: '',
|
||||||
|
historicalData: this.formBuilder.group({
|
||||||
|
csvString: ''
|
||||||
|
}),
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
scraperConfiguration: '',
|
scraperConfiguration: '',
|
||||||
symbolMapping: ''
|
symbolMapping: ''
|
||||||
@ -59,7 +63,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
public historicalDataAsCsvString: string;
|
|
||||||
public isBenchmark = false;
|
public isBenchmark = false;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataDetails: MarketData[] = [];
|
||||||
public sectors: {
|
public sectors: {
|
||||||
@ -78,7 +81,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||||
private formBuilder: FormBuilder
|
private formBuilder: FormBuilder,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
@ -88,9 +92,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public initialize() {
|
public initialize() {
|
||||||
this.historicalDataAsCsvString =
|
|
||||||
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
|
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminMarketDataBySymbol({
|
.fetchAdminMarketDataBySymbol({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
@ -131,6 +132,9 @@ 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 ?? '',
|
||||||
|
historicalData: {
|
||||||
|
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
|
||||||
|
},
|
||||||
name: this.assetProfile.name ?? this.assetProfile.symbol,
|
name: this.assetProfile.name ?? this.assetProfile.symbol,
|
||||||
scraperConfiguration: JSON.stringify(
|
scraperConfiguration: JSON.stringify(
|
||||||
this.assetProfile?.scraperConfiguration ?? {}
|
this.assetProfile?.scraperConfiguration ?? {}
|
||||||
@ -163,26 +167,46 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onImportHistoricalData() {
|
public onImportHistoricalData() {
|
||||||
const marketData = csvToJson(this.historicalDataAsCsvString, {
|
try {
|
||||||
dynamicTyping: true,
|
const marketData = csvToJson(
|
||||||
header: true,
|
this.assetProfileForm.controls['historicalData'].controls['csvString']
|
||||||
skipEmptyLines: true
|
.value,
|
||||||
}).data;
|
{
|
||||||
|
dynamicTyping: true,
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}
|
||||||
|
).data;
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.postMarketData({
|
.postMarketData({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
marketData: {
|
marketData: {
|
||||||
marketData: marketData.map(({ date, marketPrice }) => {
|
marketData: marketData.map(({ date, marketPrice }) => {
|
||||||
return { marketPrice, date: parseDate(date).toISOString() };
|
return { marketPrice, date: parseDate(date).toISOString() };
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
symbol: this.data.symbol
|
symbol: this.data.symbol
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(
|
||||||
.subscribe(() => {
|
catchError(({ error, message }) => {
|
||||||
this.initialize();
|
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
|
||||||
});
|
duration: 3000
|
||||||
|
});
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.initialize();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.snackBar.open(
|
||||||
|
$localize`Oops! Could not parse historical data.`,
|
||||||
|
undefined,
|
||||||
|
{ duration: 3000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3" formGroupName="historicalData">
|
||||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
<mat-label>
|
<mat-label>
|
||||||
<ng-container i18n>Historical Data</ng-container> (CSV)
|
<ng-container i18n>Historical Data</ng-container> (CSV)
|
||||||
@ -60,11 +60,9 @@
|
|||||||
<textarea
|
<textarea
|
||||||
cdkAutosizeMaxRows="5"
|
cdkAutosizeMaxRows="5"
|
||||||
cdkTextareaAutosize
|
cdkTextareaAutosize
|
||||||
|
formControlName="csvString"
|
||||||
matInput
|
matInput
|
||||||
placeholder="e.g. 20230601;1.61"
|
|
||||||
type="text"
|
type="text"
|
||||||
[ngModelOptions]="{standalone: true}"
|
|
||||||
[(ngModel)]="historicalDataAsCsvString"
|
|
||||||
(keyup.enter)="$event.stopPropagation()"
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@ -75,6 +73,7 @@
|
|||||||
color="accent"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
type="button"
|
type="button"
|
||||||
|
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''"
|
||||||
(click)="onImportHistoricalData()"
|
(click)="onImportHistoricalData()"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Import</ng-container>
|
<ng-container i18n>Import</ng-container>
|
||||||
@ -179,13 +178,13 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</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">
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
<mat-label i18n>Name</mat-label>
|
<mat-label i18n>Name</mat-label>
|
||||||
<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">
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
<mat-label i18n>Asset Class</mat-label>
|
<mat-label i18n>Asset Class</mat-label>
|
||||||
<mat-select formControlName="assetClass">
|
<mat-select formControlName="assetClass">
|
||||||
<mat-option [value]="null"></mat-option>
|
<mat-option [value]="null"></mat-option>
|
||||||
@ -198,7 +197,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</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">
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
<mat-label i18n>Asset Sub Class</mat-label>
|
<mat-label i18n>Asset Sub Class</mat-label>
|
||||||
<mat-select formControlName="assetSubClass">
|
<mat-select formControlName="assetSubClass">
|
||||||
<mat-option [value]="null"></mat-option>
|
<mat-option [value]="null"></mat-option>
|
||||||
|
@ -8,6 +8,7 @@ import { MatDialogModule } from '@angular/material/dialog';
|
|||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
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 { 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';
|
||||||
@ -28,6 +29,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
|||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
MatSnackBarModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
TextFieldModule
|
TextFieldModule
|
||||||
],
|
],
|
||||||
|
@ -12,7 +12,12 @@ import {
|
|||||||
PROPERTY_SYSTEM_MESSAGE,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
ghostfolioPrefix
|
ghostfolioPrefix
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
Coupon,
|
||||||
|
InfoItem,
|
||||||
|
SystemMessage,
|
||||||
|
User
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
differenceInSeconds,
|
differenceInSeconds,
|
||||||
@ -39,6 +44,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public permissions = permissions;
|
public permissions = permissions;
|
||||||
|
public systemMessage: SystemMessage;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -149,7 +155,13 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteSystemMessage() {
|
public onDeleteSystemMessage() {
|
||||||
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this system message?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation === true) {
|
||||||
|
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onFlushCache() {
|
public onFlushCache() {
|
||||||
@ -184,12 +196,21 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSetSystemMessage() {
|
public onSetSystemMessage() {
|
||||||
const systemMessage = prompt($localize`Please set your system message:`);
|
const systemMessage = prompt(
|
||||||
|
$localize`Please set your system message:`,
|
||||||
|
JSON.stringify(
|
||||||
|
this.systemMessage ??
|
||||||
|
<SystemMessage>{
|
||||||
|
message: '⚒️ Scheduled maintenance in progress...',
|
||||||
|
targetGroups: ['Basic', 'Premium']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (systemMessage) {
|
if (systemMessage) {
|
||||||
this.putAdminSetting({
|
this.putAdminSetting({
|
||||||
key: PROPERTY_SYSTEM_MESSAGE,
|
key: PROPERTY_SYSTEM_MESSAGE,
|
||||||
value: systemMessage
|
value: JSON.parse(systemMessage)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -208,6 +229,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
this.exchangeRates = exchangeRates;
|
this.exchangeRates = exchangeRates;
|
||||||
|
this.systemMessage = settings[
|
||||||
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
|
] as SystemMessage;
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
this.userCount = userCount;
|
this.userCount = userCount;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<table>
|
<table>
|
||||||
<tr *ngFor="let exchangeRate of exchangeRates">
|
<tr *ngFor="let exchangeRate of exchangeRates">
|
||||||
<td class="d-flex">
|
<td>
|
||||||
<gf-value
|
<gf-value
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[value]="1"
|
[value]="1"
|
||||||
@ -46,8 +46,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
||||||
<td class="px-1">=</td>
|
<td class="px-1">=</td>
|
||||||
<td class="d-flex justify-content-end">
|
<td align="right">
|
||||||
<gf-value
|
<gf-value
|
||||||
|
class="d-inline-block"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[precision]="4"
|
[precision]="4"
|
||||||
[value]="exchangeRate.value"
|
[value]="exchangeRate.value"
|
||||||
@ -55,26 +56,50 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<button
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[queryParams]="{
|
[matMenuTriggerFor]="exchangeRateActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#exchangeRateActionsMenu="matMenu"
|
||||||
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
|
xPosition="before"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
mat-menu-item
|
||||||
|
[queryParams]="{
|
||||||
assetProfileDialog: true,
|
assetProfileDialog: true,
|
||||||
dataSource: exchangeRate.dataSource,
|
dataSource: exchangeRate.dataSource,
|
||||||
symbol: exchangeRate.symbol
|
symbol: exchangeRate.symbol
|
||||||
}"
|
}"
|
||||||
[routerLink]="['/admin', 'market-data']"
|
[routerLink]="['/admin', 'market-data']"
|
||||||
>
|
>
|
||||||
<ion-icon name="create-outline"></ion-icon>
|
<span class="align-items-center d-flex">
|
||||||
</a>
|
<ion-icon
|
||||||
<button
|
class="mr-2"
|
||||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
name="create-outline"
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
></ion-icon>
|
||||||
mat-button
|
<span i18n>Edit</span>
|
||||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
</span>
|
||||||
>
|
</a>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<button
|
||||||
</button>
|
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-2"
|
||||||
|
name="trash-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@ -115,8 +140,8 @@
|
|||||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||||
<div class="w-50" i18n>System Message</div>
|
<div class="w-50" i18n>System Message</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div *ngIf="info?.systemMessage">
|
<div *ngIf="systemMessage" class="align-items-center d-flex">
|
||||||
<span>{{ info.systemMessage }}</span>
|
<div class="text-truncate">{{ systemMessage | json }}</div>
|
||||||
<button
|
<button
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -127,6 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
*ngIf="!info?.systemMessage"
|
*ngIf="!info?.systemMessage"
|
||||||
|
class="mt-2"
|
||||||
color="accent"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onSetSystemMessage()"
|
(click)="onSetSystemMessage()"
|
||||||
@ -148,17 +174,34 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr *ngFor="let coupon of coupons">
|
<tr *ngFor="let coupon of coupons">
|
||||||
<td class="text-monospace">{{ coupon.code }}</td>
|
<td class="text-monospace">{{ coupon.code }}</td>
|
||||||
<td class="d-flex justify-content-end pl-2">
|
<td class="pl-2 text-right">{{ coupon.duration }}</td>
|
||||||
{{ coupon.duration }}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
class="h-100 mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
(click)="onDeleteCoupon(coupon.code)"
|
[matMenuTriggerFor]="couponActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#couponActionsMenu="matMenu"
|
||||||
|
class="h-100 mx-1 no-min-width px-2"
|
||||||
|
xPosition="before"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onDeleteCoupon(coupon.code)"
|
||||||
|
>
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-2"
|
||||||
|
name="trash-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
@ -20,6 +21,7 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
|||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
|
MatMenuModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
|
@ -68,7 +68,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="px-1 text-center"
|
class="px-1 text-center"
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="px-1 text-center"
|
class="px-1 text-center"
|
||||||
|
@ -178,7 +178,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions" stickyEnd>
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="mat-mdc-header-cell px-1 py-2"
|
class="mat-mdc-header-cell px-1 py-2"
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
|
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
|
||||||
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-fear-and-greed-index',
|
selector: 'gf-fear-and-greed-index',
|
||||||
@ -24,9 +25,9 @@ export class FearAndGreedIndexComponent implements OnChanges, OnInit {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
const { emoji, text } = resolveFearAndGreedIndex(this.fearAndGreedIndex);
|
const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex);
|
||||||
|
|
||||||
this.fearAndGreedIndexEmoji = emoji;
|
this.fearAndGreedIndexEmoji = emoji;
|
||||||
this.fearAndGreedIndexText = text;
|
this.fearAndGreedIndexText = translate(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-
|
|||||||
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 {
|
import {
|
||||||
STAY_SIGNED_IN,
|
KEY_STAY_SIGNED_IN,
|
||||||
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';
|
||||||
@ -196,7 +196,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
public setToken(aToken: string) {
|
public setToken(aToken: string) {
|
||||||
this.tokenStorageService.saveToken(
|
this.tokenStorageService.saveToken(
|
||||||
aToken,
|
aToken,
|
||||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'
|
||||||
);
|
);
|
||||||
|
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
|
@ -31,6 +31,7 @@
|
|||||||
<gf-benchmark
|
<gf-benchmark
|
||||||
[benchmarks]="benchmarks"
|
[benchmarks]="benchmarks"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[user]="user"
|
||||||
></gf-benchmark>
|
></gf-benchmark>
|
||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
*ngIf="isLoading"
|
*ngIf="isLoading"
|
||||||
|
@ -33,6 +33,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public isLoadingPerformance = true;
|
public isLoadingPerformance = true;
|
||||||
public performance: PortfolioPerformance;
|
public performance: PortfolioPerformance;
|
||||||
public showDetails = false;
|
public showDetails = false;
|
||||||
|
public unit: string;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -76,6 +77,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
!this.hasImpersonationId &&
|
!this.hasImpersonationId &&
|
||||||
!this.user.settings.isRestrictedView &&
|
!this.user.settings.isRestrictedView &&
|
||||||
this.user.settings.viewMode !== 'ZEN';
|
this.user.settings.viewMode !== 'ZEN';
|
||||||
|
|
||||||
|
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
public onChangeDateRange(dateRange: DateRange) {
|
public onChangeDateRange(dateRange: DateRange) {
|
||||||
|
@ -86,7 +86,6 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<gf-portfolio-performance
|
<gf-portfolio-performance
|
||||||
class="pb-4"
|
class="pb-4"
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
[errors]="errors"
|
[errors]="errors"
|
||||||
[isAllTimeHigh]="isAllTimeHigh"
|
[isAllTimeHigh]="isAllTimeHigh"
|
||||||
@ -95,6 +94,7 @@
|
|||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[performance]="performance"
|
[performance]="performance"
|
||||||
[showDetails]="showDetails"
|
[showDetails]="showDetails"
|
||||||
|
[unit]="unit"
|
||||||
></gf-portfolio-performance>
|
></gf-portfolio-performance>
|
||||||
<div *ngIf="showDetails" class="text-center">
|
<div *ngIf="showDetails" class="text-center">
|
||||||
<gf-toggle
|
<gf-toggle
|
||||||
|
@ -4,7 +4,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
|
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
|
||||||
import {
|
import {
|
||||||
STAY_SIGNED_IN,
|
KEY_STAY_SIGNED_IN,
|
||||||
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';
|
||||||
@ -31,7 +31,7 @@ export class LoginWithAccessTokenDialog {
|
|||||||
|
|
||||||
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
|
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
|
||||||
this.settingsStorageService.setSetting(
|
this.settingsStorageService.setSetting(
|
||||||
STAY_SIGNED_IN,
|
KEY_STAY_SIGNED_IN,
|
||||||
aValue.checked?.toString()
|
aValue.checked?.toString()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,17 +35,7 @@
|
|||||||
<span #value id="value"></span>
|
<span #value id="value"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1 px-1">
|
<div class="flex-grow-1 px-1">
|
||||||
<ngx-skeleton-loader
|
{{ unit }}
|
||||||
*ngIf="isLoading"
|
|
||||||
animation="pulse"
|
|
||||||
[theme]="{
|
|
||||||
height: '1.3rem',
|
|
||||||
width: '2.5rem'
|
|
||||||
}"
|
|
||||||
></ngx-skeleton-loader>
|
|
||||||
<div *ngIf="!isLoading">
|
|
||||||
{{ unit }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="showDetails" class="row">
|
<div *ngIf="showDetails" class="row">
|
||||||
|
@ -25,7 +25,6 @@ import { isNumber } from 'lodash';
|
|||||||
styleUrls: ['./portfolio-performance.component.scss']
|
styleUrls: ['./portfolio-performance.component.scss']
|
||||||
})
|
})
|
||||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||||
@Input() baseCurrency: string;
|
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
@Input() errors: ResponseError['errors'];
|
@Input() errors: ResponseError['errors'];
|
||||||
@Input() isAllTimeHigh: boolean;
|
@Input() isAllTimeHigh: boolean;
|
||||||
@ -34,11 +33,10 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() performance: PortfolioPerformance;
|
@Input() performance: PortfolioPerformance;
|
||||||
@Input() showDetails: boolean;
|
@Input() showDetails: boolean;
|
||||||
|
@Input() unit: string;
|
||||||
|
|
||||||
@ViewChild('value') value: ElementRef;
|
@ViewChild('value') value: ElementRef;
|
||||||
|
|
||||||
public unit: string;
|
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
@ -50,8 +48,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isNumber(this.performance?.currentValue)) {
|
if (isNumber(this.performance?.currentValue)) {
|
||||||
this.unit = this.baseCurrency;
|
|
||||||
|
|
||||||
new CountUp('value', this.performance?.currentValue, {
|
new CountUp('value', this.performance?.currentValue, {
|
||||||
decimal: getNumberFormatDecimal(this.locale),
|
decimal: getNumberFormatDecimal(this.locale),
|
||||||
decimalPlaces:
|
decimalPlaces:
|
||||||
@ -63,8 +59,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
separator: getNumberFormatGroup(this.locale)
|
separator: getNumberFormatGroup(this.locale)
|
||||||
}).start();
|
}).start();
|
||||||
} else if (this.performance?.currentValue === null) {
|
} else if (this.performance?.currentValue === null) {
|
||||||
this.unit = '%';
|
|
||||||
|
|
||||||
new CountUp(
|
new CountUp(
|
||||||
'value',
|
'value',
|
||||||
this.performance?.currentNetPerformancePercent * 100,
|
this.performance?.currentNetPerformancePercent * 100,
|
||||||
|
@ -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,
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
<div class="d-flex mr-2">
|
<div class="d-flex mr-2">
|
||||||
<gf-trend-indicator
|
<gf-trend-indicator
|
||||||
class="d-flex"
|
class="d-flex"
|
||||||
|
size="large"
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[marketState]="position?.marketState"
|
[marketState]="position?.marketState"
|
||||||
[range]="range"
|
[range]="range"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
<div class="container">
|
<div class="align-items-center container d-flex h-100 justify-content-center">
|
||||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
|
<div class="row w-100">
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="align-items-center d-flex flex-column">
|
<div class="align-items-center d-flex flex-column">
|
||||||
<gf-membership-card
|
<gf-membership-card
|
||||||
@ -34,7 +33,7 @@
|
|||||||
> <span i18n>per year</span>
|
> <span i18n>per year</span>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div class="align-items-center d-flex justfiy-content-center mt-4">
|
<div class="align-items-center d-flex justify-content-center mt-4">
|
||||||
<a
|
<a
|
||||||
*ngIf="!user?.subscription?.expiresAt"
|
*ngIf="!user?.subscription?.expiresAt"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
|
@ -8,7 +8,8 @@ import {
|
|||||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import {
|
import {
|
||||||
STAY_SIGNED_IN,
|
KEY_STAY_SIGNED_IN,
|
||||||
|
KEY_TOKEN,
|
||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -44,6 +45,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
'fr',
|
'fr',
|
||||||
'it',
|
'it',
|
||||||
'nl',
|
'nl',
|
||||||
|
'pl',
|
||||||
'pt',
|
'pt',
|
||||||
'tr'
|
'tr'
|
||||||
];
|
];
|
||||||
@ -240,7 +242,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
|
this.settingsStorageService.removeSetting(KEY_STAY_SIGNED_IN);
|
||||||
|
this.settingsStorageService.removeSetting(KEY_TOKEN);
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
});
|
});
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user