Compare commits

...

115 Commits

Author SHA1 Message Date
0feba4b8d9 Release 2.35.0 (#2831) 2024-01-06 10:45:26 +01:00
62f85293e2 #2820 Grant private access (#2822)
* Grant private access

* Update changelog
2024-01-06 10:27:21 +01:00
6a048cee85 Feature/add hint for twr to portfolio summary (#2824)
* Add hint for TWR

* Add TWR to README.md

* Update changelog
2024-01-06 09:31:59 +01:00
0d93612d16 Feature/improve style of assistant (#2828)
* Minor style improvements

* Update changelog
2024-01-06 09:14:48 +01:00
9bf68b0d20 Feature/enable redis authentication in docker compose files (#2805)
* Enable redis authentication in docker-compose files

* Update changelog
2024-01-04 20:14:45 +01:00
371f1dc451 Feature/support rest api in scraper (#2810)
* Support REST APIs in scraper

* Update changelog
2024-01-03 21:59:45 +01:00
5cb2ec6411 Feature/Improve user interface of access table (#2821)
* Improve alignment

* Update changelog
2024-01-03 21:08:35 +01:00
3723a1d8b8 Release 2.34.0 (#2817) 2024-01-02 17:05:12 +01:00
4c30e9459d Feature/extend assistant by date range selector (#2815)
* Extend assistant by date range selector

* Update changelog
2024-01-02 17:02:15 +01:00
23d323073d Fix performance percentage for 1d (#2814)
* Fix performance percentage for 1d

* Improve response of positions endpoint

* Update changelog
2024-01-02 14:10:08 +01:00
0ad734262a Bugfix/improve tabs on ios (#2811)
* Improve tabs on iOS (Add to Home Screen)

* Update changelog
2024-01-02 10:06:13 +01:00
0649f9fd2c Clean up (#2787) 2024-01-02 10:05:31 +01:00
d089662dab Feature/improve the style of the top 3 and bottom 3 performers (#2807)
* Refactor to ordered list

* Update changelog
2024-01-02 09:44:15 +01:00
8c1c336fc6 Feature/upgrade nx to version 17.2.8 (#2809)
* Upgrade Nx to version 17.2.8

* Update changelog
2024-01-01 17:14:53 +01:00
43b4f14ace Feature/add button to test scraper configuration (#2808)
* Add button to test scraper configuration

* Update changelog

---------

Co-authored-by: Manushreshta B L <manushreshta27@gmail.com>
Co-authored-by: Hugo Persson <hugo.e.persson@gmail.com>
2024-01-01 11:53:42 +01:00
3717e38845 Update year (#2803) 2024-01-01 10:11:04 +01:00
265d4d0450 Release 2.33.0 (#2806) 2023-12-31 13:30:27 +01:00
726e727c7d Feature/benchmark currency correction (#2790)
* Convert benchmark performance to base currency

* Introduce getExchangeRates() for multiple dates

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2023-12-31 13:28:11 +01:00
cb664774c0 Update translations (#2798)
* Update translations
2023-12-31 10:33:12 +01:00
b89bf1d5e8 Feature/increase timeout to load currencies (#2800)
* Increase timeout

* Update changelog
2023-12-31 10:23:56 +01:00
53ce37a83a Update OSS friends (#2799) 2023-12-31 10:23:13 +01:00
e9ac9057ff Fix debug instructions (#2802) 2023-12-30 21:11:56 +01:00
7020fc2a93 Feature/add hint for community language support (#2793)
* Add hint

* Update changelog
2023-12-30 10:55:11 +01:00
efcd9539dd Feature/upgrade ng extract i18n merge to 2.9.1 (#2797)
* Upgrade ng-extract-i18n-merge to version 2.9.1

* Update changelog
2023-12-30 10:54:04 +01:00
61ecc48d0e Feature/improve language localization for de 20231229 (#2796)
* Improve language localizations

* Update changelog
2023-12-30 10:34:17 +01:00
e465f1b791 Feature/add support to edit currency of asset profile with data source manual (#2789)
* Add support to edit currency

* Update changelog
2023-12-29 19:55:51 +01:00
01b6c14bcc Feature/improve handling of derived currency usx (#2788)
* Improve handling of USX

* Update changelog
2023-12-29 17:31:50 +01:00
34b02210df Feature/expose the environment variable REQUEST_TIMEOUT (#2792)
* Expose the environment variable `REQUEST_TIMEOUT`

* Update changelog
2023-12-29 17:29:33 +01:00
0034776b34 Feature/upgrade nx to version 17.2.7 (#2781)
* Upgrade Nx to version 17.2.7

* Update changelog
2023-12-29 11:26:24 +01:00
b183c45027 Time weighted portfolio performance calculation (#2778)
* Implement time weighted portfolio performance calculation

* Update changelog
2023-12-27 15:55:35 +01:00
7d68905f1b Feature/use has permission annotation in endpoints (#2771)
* Use HasPermission in endpoints

* Update changelog
2023-12-26 19:23:25 +01:00
0953c072fe Release 2.32.0 (#2784) 2023-12-26 10:18:32 +01:00
d152187ee8 Feature/upgrade prisma to version 5.7.1 (#2780)
* Upgrade prisma to version 5.7.1

* Update changelog
2023-12-26 10:16:20 +01:00
3c5affce88 Feature/upgrade prettier to version 3.1.1 (#2768)
* Upgrade prettier to version 3.1.1

* Update changelog
2023-12-24 16:23:17 +01:00
f27e21f9a0 Extend issue template (#2776) 2023-12-23 17:45:37 +01:00
337ca328c3 Feature/drop activity id on import (#2769)
* Drop activity id on import

* Update changelog
2023-12-22 20:16:02 +01:00
beb9e2c43f Feature/modernize nx executors (#2767)
* Modernize Nx executors

* @nx/eslint:lint
* @nx/webpack:webpack

* Update changelog
2023-12-21 11:44:36 +01:00
4d79df90a7 Feature/support search by asset profile id (#2765)
* Add support to search for an asset profile by id

* Update changelog
2023-12-20 19:24:03 +01:00
aa72d9b730 Feature/improve validation of currency management (#2761)
* Improve validation

* Update changelog
2023-12-20 11:53:40 +01:00
80e899a5d3 Bugfix/reset letter spacing in buttons (#2762)
* Reset letter spacing

* Update changelog
2023-12-19 19:54:20 +01:00
7c33120546 Reimplement redactObject() without cloneDeep() (#2760)
* Reimplement redactObject() without cloneDeep()

* Update changelog
2023-12-19 19:53:37 +01:00
7f3c86038f Sort imports (#2739) 2023-12-18 19:52:57 +01:00
c1446f8559 Feature/set select column to sticky in lazy loaded activities table (#2755)
* Set select column to sticky

* Update changelog
2023-12-17 20:09:56 +01:00
88d5dfe435 Release 2.31.0 (#2757) 2023-12-16 20:01:38 +01:00
7dc8f80fdf Feature/upgrade to nx version 17.2.5 (#2756)
* Upgrade Nx to version 17.2.5

* Update changelog
2023-12-16 20:00:04 +01:00
96f90c7259 Feature/add lazy loaded activities table to import activities dialog (#2754)
* Add lazy-loaded activities table

* Update changelog
2023-12-16 19:23:01 +01:00
a10d9cb6ba Feature/add lazy loaded activities table to account detail dialog (#2752)
* Add lazy-loaded activities table

* Update changelog
2023-12-16 19:04:08 +01:00
4547c5da1d Feature/add lazy loaded activities table to position detail dialog (#2753)
* Add lazy-loaded activities table

* Update changelog
2023-12-16 17:10:19 +01:00
28706d7b26 Refactor order service (#2751) 2023-12-16 17:08:59 +01:00
492bc5e17b Feature/improve font weight in value component (#2747)
* Improve font weight

* Update changelog
2023-12-16 14:41:50 +01:00
6c37737051 Bugfix/fix loading state of lazy loaded activities table component (#2744)
* Fix loading state

* Update changelog
2023-12-16 10:24:04 +01:00
8677d20c2c Feature/upgrade inter to version 4 (#2746)
* Upgrade to Inter 4

* Update changelog
2023-12-16 10:23:08 +01:00
4d905065ad Extend turkish translation (#2750) 2023-12-16 09:23:42 +01:00
5599b41b83 Bugfix/fix edit of activity in lazy loaded activities table (#2749)
* Fix edit

* Update changelog
2023-12-16 09:19:09 +01:00
8d5a60d777 Update turkish locales (#2748)
* Update translation

* Update changelog
2023-12-15 08:15:51 +01:00
695acf4f3f Update turkish translation (#2731)
* Update locales

* Update changelog
2023-12-14 20:04:22 +01:00
67dbef3b7a Release 2.30.0 (#2743) 2023-12-12 19:56:46 +01:00
0e94112dc7 Feature/adjust holdings weight threshold in trackinsight data enhancer (#2742)
* Adjust holdings weight threshold

* Update changelog
2023-12-12 19:54:58 +01:00
b22edff16b Feature/add support for column sorting to lazy loaded activities table (#2738)
* Add support for column sorting

* Update changelog
2023-12-12 19:54:40 +01:00
ffb7cbff50 Feature/highlight all time high in benchmarks of the markets overview (#2740)
* Highlight all time high

* Update changelog
2023-12-11 19:45:30 +01:00
25424ad280 Feature/update prisma to version 5.7.0 (#2737)
* Update prisma to version 5.7.0

* Update changelog
2023-12-10 14:50:32 +01:00
a768902b00 Feature/update prisma to version 5.7.0 (#2734)
* Update prisma to version 5.7.0

* Update changelog
2023-12-09 17:24:25 +01:00
2c7ece50fe Release 2.29.0 (#2733) 2023-12-09 17:14:13 +01:00
51a0ede3e4 Feature/introduce lazy loaded activities table (#2729)
* Introduce lazy-loaded activities table

* Add icon column

* Emit paginator event

* Add pagination logic

* Integrate total items

* Update changelog
2023-12-09 17:12:09 +01:00
531964636b Fix type (#2708) 2023-12-08 19:59:06 +01:00
e461fff1d7 Extend turkish localization (#2730) 2023-12-07 14:13:28 +01:00
4f9a5f0340 Feature/set actions columns of tables to stick at end (#2726)
* Set up stickyEnd in actions columns

* Update changelog
2023-12-06 18:35:03 +01:00
8d80e840b8 Feature/upgrade ngx markdown to version 17.1.1 (#2714)
* Upgrade marked and ngx-markdown

* Update changelog
2023-12-05 18:16:03 +01:00
833982a9de Bugfix/fix biometric authentication registration (#2713)
* Remove token on device registration

* Update changelog
2023-12-05 18:13:35 +01:00
c85966e5ed Improve language localization for tr (#2717)
* Improve language localization for tr

* Update changelog
2023-12-04 21:04:20 +01:00
43f67ba832 Feature/upgrade ng extract i18n merge to version 2.9.0 (#2715)
* Upgrade ng-extract-i18n-merge to version 2.9.0

* Update changelog
2023-12-04 19:54:43 +01:00
cbea8ac9d3 Feature/increase tab height on mobile (#2712)
* Increase tab height on mobile

* Update changelog
2023-12-04 19:53:50 +01:00
d4c939e41d Feature/improve language localization for german 20231202 (#2710)
* Update locales

* Update changelog
2023-12-03 09:26:12 +01:00
c1f129501a Release 2.28.0 (#2709) 2023-12-02 17:20:10 +01:00
377ba75e4c Add support to delete a cash balance (#2707) 2023-12-02 17:17:25 +01:00
77b13b88f0 Relax check for duplicates in activities import (#2704)
* Relax check for duplicates in activities import (allow same day)

* Update changelog
2023-12-02 10:37:03 +01:00
813e73a0a3 Introduce HasPermission annotation (#2693)
* Introduce HasPermission annotation

* Update changelog
2023-12-02 10:21:19 +01:00
1d796a9597 Bugfix/change intraday data gathering to operate synchronously (#2705)
* Change intraday data gathering to operate synchronously

* Update changelog
2023-12-02 10:20:05 +01:00
4eedf64a3c Update OSS Friends (#2701) 2023-12-02 10:00:00 +01:00
ed4dd79c72 Add cash balances table to account detail dialog (#2549)
* Add cash balances table to account detail dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-12-01 21:12:41 +01:00
6f4fd0826c Feature/respect with excluded accounts flag in get account balances (#2697)
* Respect withExcludedAccounts in getAccountBalances()

* Update changelog
2023-12-01 17:22:13 +01:00
8e3a144a37 Fix date (#2699) 2023-11-30 07:43:41 +01:00
07b0a2c40a Add guard (#2696) 2023-11-29 20:10:37 +01:00
c5dc3d4272 Release 2.27.1 (#2698) 2023-11-28 07:53:00 +01:00
73e69273b4 Upgrade Nx to version 17.1.3 (#2694) 2023-11-27 18:47:15 +01:00
e0b74ef418 Release 2.27.0 (#2692) 2023-11-26 21:18:37 +01:00
2b491dc732 Extend performance endpoint by net worth per day (#2574)
* Extend performance endpoint by net worth per day

* Update changelog
2023-11-26 21:17:15 +01:00
79fc22b5ae Change tweet to post (#2684) 2023-11-26 08:30:02 +01:00
0a83bcd697 Feature/improve error log for data source request timeout (#2688)
* Improve error log for timeouts

* Update changelog
2023-11-25 18:50:24 +01:00
52540d460b Fix alignment (#2689) 2023-11-25 18:48:54 +01:00
6ff2e0f952 Introduce base product page (#2687) 2023-11-25 18:48:39 +01:00
b3e72383bc Feature/extract locales 20231125 (#2686)
* Update locales

* Update changelog
2023-11-25 12:46:56 +01:00
bdfba4d509 Upgrade to Nx 17.1 (#2635)
* Upgrade to Nx 17

* Run migrations

* Extend instructions

* Update changelog
2023-11-25 12:31:45 +01:00
8a411b707d Release 2.26.0 (#2685) 2023-11-24 19:17:54 +01:00
e21601202e update ci and add permissions block (#2683) 2023-11-24 10:20:26 +01:00
8f66040df1 Feature/upgrade prisma to version 5.6.0 (#2680)
* Upgrade prisma to version 5.6.0

* Update changelog
2023-11-23 15:16:01 +01:00
5ad248a643 Improve algorithm (#2676) 2023-11-23 15:15:40 +01:00
fa36c42af4 Improve yeekatee comparison data (#2682) 2023-11-23 15:14:59 +01:00
d4ddc781e1 Feature/upgrade yahoo finance2 to version 2.9.0 (#2679)
* Upgrade yahoo-finance2 to version 2.9.0

* Upgdate changelog
2023-11-22 08:11:04 +01:00
386dd56590 Feature/extend personal finance tools pages 20231120 (#2678)
* Improve tags

* Add Compound Planning

* Add Whal

* Add De.Fi

* Add Tiller

* Add Empower
2023-11-22 08:10:42 +01:00
f28b13604a Add as const (#2677) 2023-11-21 20:12:59 +01:00
d827858d0b Release 2.25.1 (#2674) 2023-11-19 16:46:32 +01:00
c758ca4bfa Release 2.25.0 (#2673) 2023-11-19 16:30:36 +01:00
37183a07bd Change black friday to black week (#2672)
* Change black friday to black week

* Change image
2023-11-19 16:28:30 +01:00
fb294fc6e2 Improve wording (#2668) 2023-11-18 11:15:03 +01:00
8898d02442 Bugfix/fix cannot read properties of undefined reading items in get position (#2667)
* Fix "Cannot read properties of undefined (reading 'items')"

* Update changelog
2023-11-18 11:05:05 +01:00
232d30234c Update OSS Friends (#2663) 2023-11-18 09:59:27 +01:00
e2234c4966 Feature/add black friday 2023 blog post (#2664)
* Add blog post: Black Friday 2023

* Update changelog
2023-11-17 20:20:49 +01:00
272a34195b Refactor folder (#2665) 2023-11-17 20:09:19 +01:00
8c25294da7 Feature/upgrade http status codes to version 2.3.0 (#2644)
* Upgrade http-status-codes to version 2.3.0

* Update changelog
2023-11-17 20:08:23 +01:00
6f11627006 Release 2.24.0 (#2661) 2023-11-16 20:29:49 +01:00
215098e418 Feature/improve language localization for german 20231116 (#2660)
* Update locales

* Update changelog
2023-11-16 20:28:09 +01:00
781496383b Bugfix/improve get range query in market data service (#2659)
* Attempt to fix "too many bind variables in prepared statement, expected maximum of 32767"

* Update changelog
2023-11-16 20:22:56 +01:00
f0f304c012 Change tweet to post (#2658) 2023-11-16 20:22:18 +01:00
4bf97c104b Update changelog (#2656) 2023-11-15 21:45:38 +01:00
309 changed files with 29421 additions and 13006 deletions

View File

@ -6,7 +6,13 @@ labels: ''
assignees: ''
---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Important Notice**
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
Thank you for your understanding and cooperation!
**Bug Description**
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information -->
- Cloud or Self-hosted
- Ghostfolio Version X.Y.Z
- Cloud or Self-hosted
- Experimental Features enabled or disabled
- Browser
- OS

View File

@ -4,6 +4,9 @@ on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@ -13,12 +16,12 @@ jobs:
- 18
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker metadata
id: meta

View File

@ -1,3 +1,7 @@
/.nx/cache
# Issue: https://github.com/prettier/prettier/issues/15650
/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
/dist
/test/import

View File

@ -5,6 +5,194 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.35.0 - 2024-01-06
### Added
- Added support to grant private access
- Added a hint for _Time-Weighted Rate of Return_ (TWR) to the portfolio summary tab on the home page
- Added support for REST APIs (`JSON`) via the scraper configuration
- Enabled the _Redis_ authentication in the `docker-compose` files
### Changed
- Improved the user interface of the access table to share the portfolio
- Improved the style of the assistant (experimental)
## 2.34.0 - 2024-01-02
### Added
- Extended the assistant by a date range selector (experimental)
- Added a button to test the scraper configuration in the asset profile details dialog of the admin control
### Changed
- Improved the style of the _Top 3_ and _Bottom 3_ performers on the analysis page
- Upgraded `Nx` from version `17.2.7` to `17.2.8`
### Fixed
- Improved the time-weighted performance calculation for `1D`
- Improved the tabs on iOS (_Add to Home Screen_)
## 2.33.0 - 2023-12-31
### Added
- Added support to edit the currency of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
- Added a hint for the community languages in the user settings
### Changed
- Changed the performance calculation to a time-weighted approach
- Normalized the benchmark by currency in the benchmark comparator
- Increased the timeout to load currencies in the exchange rate data service
- Exposed the environment variable `REQUEST_TIMEOUT`
- Used the `HasPermission` annotation in endpoints
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.9.0` to `2.9.1`
- Upgraded `Nx` from version `17.2.5` to `17.2.7`
### Fixed
- Improved the handling of derived currencies (`USX`)
## 2.32.0 - 2023-12-26
### Added
- Added support to search for an asset profile by `id` as an administrator
### Changed
- Set the select column of the lazy-loaded activities table to stick at the end (experimental)
- Dropped the activity id in the activities import
- Improved the validation of the currency management in the admin control panel
- Improved the performance of the value redaction interceptor for the impersonation mode by eliminating `cloneDeep`
- Modernized the `Nx` executors
- `@nx/eslint:lint`
- `@nx/webpack:webpack`
- Upgraded `prettier` from version `3.1.0` to `3.1.1`
- Upgraded `prisma` from version `5.7.0` to `5.7.1`
### Fixed
- Reset the letter spacing in buttons
## 2.31.0 - 2023-12-16
### Changed
- Introduced the lazy-loaded activities table to the account detail dialog (experimental)
- Introduced the lazy-loaded activities table to the import activities dialog (experimental)
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
- Improved the font weight in the value component
- Improved the language localization for Türkçe (`tr`)
- Upgraded `angular` from version `17.0.4` to `17.0.7`
- Upgraded to _Inter_ 4 font family
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
### Fixed
- Fixed the loading state in the lazy-loaded activities table on the portfolio activities page (experimental)
- Fixed the edit of activity in the lazy-loaded activities table on the portfolio activities page (experimental)
## 2.30.0 - 2023-12-12
### 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
@ -16,7 +204,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the data source validation in the activities import
- Changed _Twitter_ to _𝕏_
- Improved selection in the twitter bot service
- 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`
@ -108,7 +296,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
- 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
@ -169,7 +357,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Changed the users table in the admin control panel to an `@angular/material` data table
- Improved the styling of the membership status
- Improved the style of the membership status
### Fixed
@ -1344,7 +1532,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the styling in the admin control panel
- Improved the style in the admin control panel
- Removed the _Google Play_ badge from the landing page
- Upgraded `eslint` dependencies
@ -2099,7 +2287,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplified the initialization of the exchange rate service
- Improved the orders query for `assetClass` with symbol profile overrides
- Improved the styling of the benchmarks in the markets overview
- Improved the style of the benchmarks in the markets overview
### Todo
@ -2433,7 +2621,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed a styling issue in the benchmark component on mobile
- Fixed a style issue in the benchmark component on mobile
## 1.152.0 - 26.05.2022
@ -3040,7 +3228,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed the styling in the footer row of the activities table
- Fixed the style in the footer row of the activities table
## 1.106.0 - 23.01.2022
@ -3808,7 +3996,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the wording for the _Restricted View_: _Presenter View_
- Improved the styling of the tables
- Improved the style of the tables
- Ignored cash assets in the allocation chart by sector, continent and country
### Fixed
@ -4011,8 +4199,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Improved the style of the current pricing plan
- Improved the style of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
@ -4372,7 +4560,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the users table styling of the admin control panel
- Improved the users table style of the admin control panel
- Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021
@ -4396,7 +4584,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the styling of the rules in the _X-ray_ section
- Improved the style of the rules in the _X-ray_ section
## 0.90.0 - 22.04.2021
@ -4591,7 +4779,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the alignment of the _Why Ghostfolio?_ section
- Improved the styling of the _Fear & Greed Index_ (market mood)
- Improved the style of the _Fear & Greed Index_ (market mood)
## 0.73.0 - 31.03.2021
@ -4637,7 +4825,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the styling in the _X-ray_ section
- Improved the style in the _X-ray_ section
## 0.70.0 - 27.03.2021
@ -4932,7 +5120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Only show relevant data in the position detail dialog
- Improved the performance chart styling in Safari
- Improved the performance chart style in Safari
## 0.40.0 - 01.03.2021

View File

@ -32,7 +32,7 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
1. Run `yarn nx migrate latest`
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

View File

@ -49,7 +49,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions
@ -100,6 +100,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose
@ -165,7 +166,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
#### Debug
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
@ -272,12 +273,12 @@ 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.
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).
## License
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -7,14 +7,15 @@
"generators": {},
"targets": {
"build": {
"executor": "@nrwl/webpack:webpack",
"executor": "@nx/webpack:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"target": "node",
"compiler": "tsc"
"compiler": "tsc",
"webpackConfig": "apps/api/webpack.config.js"
},
"configurations": {
"production": {
@ -39,7 +40,7 @@
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
@ -47,8 +48,7 @@
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
"jestConfig": "apps/api/jest.config.ts"
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}

View File

@ -1,5 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Access } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -17,7 +20,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@ -25,11 +27,12 @@ import { CreateAccessDto } from './create-access.dto';
export class AccessController {
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({
include: {
@ -58,13 +61,15 @@ export class AccessController {
});
}
@HasPermission(permissions.createAccess)
@Post()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccess(
@Body() data: CreateAccessDto
): Promise<AccessModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createAccess)
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -72,25 +77,29 @@ export class AccessController {
);
}
return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
User: { connect: { id: this.request.user.id } }
});
try {
return await this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
User: { connect: { id: this.request.user.id } }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
@HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id });
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) {
if (!access || access.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN

View File

@ -1,3 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { AccessService } from './access.service';
@Module({
controllers: [AccessController],
exports: [AccessService],
imports: [PrismaModule],
imports: [ConfigurationModule, PrismaModule],
providers: [AccessService]
})
export class AccessModule {}

View File

@ -1,4 +1,4 @@
import { IsOptional, IsString } from 'class-validator';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@ -6,7 +6,7 @@ export class CreateAccessDto {
alias?: string;
@IsOptional()
@IsString()
@IsUUID()
granteeUserId?: string;
@IsOptional()

View File

@ -0,0 +1,48 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
@Controller('account-balance')
export class AccountBalanceController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.deleteAccountBalance)
@Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccountBalance(
@Param('id') id: string
): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({
id
});
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.deleteAccountBalance({
id
});
}
}

View 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 {}

View 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
)
};
})
};
}
}

View File

@ -1,13 +1,15 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
AccountBalancesResponse,
Accounts
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import type {
AccountWithValue,
RequestWithUser
@ -47,17 +49,9 @@ export class AccountController {
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders(
{
id_userId: {
@ -87,7 +81,7 @@ export class AccountController {
}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
@ -102,7 +96,7 @@ export class AccountController {
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@ -122,31 +116,23 @@ export class AccountController {
}
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
accountId: id,
userId: this.request.user.id
filters: [{ id, type: 'ACCOUNT' }],
user: this.request.user
});
}
@HasPermission(permissions.createAccount)
@Post()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
@ -172,20 +158,12 @@ export class AccountController {
}
}
@HasPermission(permissions.updateAccount)
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id
);
@ -234,18 +212,10 @@ export class AccountController {
});
}
@HasPermission(permissions.updateAccount)
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({
id_userId: {
id,

View File

@ -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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';

View File

@ -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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';

View File

@ -1,6 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
@ -17,7 +20,7 @@ import {
AdminMarketDataDetails,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import type {
MarketDataPreset,
RequestWithUser
@ -29,6 +32,7 @@ import {
Get,
HttpException,
Inject,
Logger,
Param,
Patch,
Post,
@ -54,61 +58,29 @@ export class AdminController {
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.get();
}
@HasPermission(permissions.accessAdminControl)
@Post('gather')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days();
}
@HasPermission(permissions.accessAdminControl)
@Post('gather/max')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue(
@ -130,21 +102,10 @@ export class AdminController {
this.dataGatheringService.gatherMax();
}
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue(
@ -164,24 +125,13 @@ export class AdminController {
);
}
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.dataGatheringService.addJobToQueue({
data: {
dataSource,
@ -196,47 +146,25 @@ export class AdminController {
}
@Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@HasPermission(permissions.accessAdminControl)
@Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString);
if (!isDate(date)) {
@ -254,7 +182,8 @@ export class AdminController {
}
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@ -264,18 +193,6 @@ export class AdminController {
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterBySearchQuery
@ -292,45 +209,47 @@ export class AdminController {
}
@Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData(
@Body() data: { scraperConfiguration: string },
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<{ price: number }> {
try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
if (price) {
return { price };
}
throw new Error('Could not parse the current market price');
} catch (error) {
Logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
}
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
@ -349,26 +268,15 @@ export class AdminController {
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
@ -383,24 +291,14 @@ export class AdminController {
});
}
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({
dataSource,
symbol,
@ -409,45 +307,23 @@ export class AdminController {
}
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
@ -455,24 +331,13 @@ export class AdminController {
});
}
@HasPermission(permissions.accessAdminControl)
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
}
}

View File

@ -176,6 +176,7 @@ export class AdminService {
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
@ -320,6 +321,7 @@ export class AdminService {
assetClass,
assetSubClass,
comment,
currency,
dataSource,
name,
scraperConfiguration,
@ -330,6 +332,7 @@ export class AdminService {
assetClass,
assetSubClass,
comment,
currency,
dataSource,
name,
scraperConfiguration,

View File

@ -1,87 +1,48 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { permissions } from '@ghostfolio/common/permissions';
import {
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { QueueService } from './queue.service';
@Controller('admin/queue')
export class QueueController {
public constructor(
private readonly queueService: QueueService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly queueService: QueueService) {}
@Delete('job')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJobs(
@Query('status') filterByStatus?: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.deleteJobs({ status });
}
@Get('job')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJobs(
@Query('status') filterByStatus?: string
): Promise<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.getJobs({ status });
}
@Delete('job/:id')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJob(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.deleteJob(id);
}
}

View File

@ -14,6 +14,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
comment?: string;
@IsString()
@IsOptional()
currency?: string;
@IsString()
@IsOptional()
name?: string;

View File

@ -1,40 +1,18 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly authDeviceService: AuthDeviceService) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id });
}
}

View File

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
@ -118,13 +119,13 @@ export class AuthController {
}
@Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() {
return this.webAuthService.generateRegistrationOptions();
}
@Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import type {
@ -5,7 +7,7 @@ import type {
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -33,21 +35,10 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl)
@Post()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.addBenchmark({
dataSource,
@ -71,23 +62,12 @@ export class BenchmarkController {
}
@Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource,
@ -120,7 +100,7 @@ export class BenchmarkController {
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@ -128,11 +108,13 @@ export class BenchmarkController {
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
symbol,
userCurrency
});
}
}

View File

@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
PropertyModule,

View File

@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
null,
null,
null,
null,
null
);
});

View File

@ -1,6 +1,7 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -11,20 +12,22 @@ import {
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
calculateBenchmarkTrend
calculateBenchmarkTrend,
parseDate
} from '@ghostfolio/common/helper';
import {
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty,
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { format, subDays } from 'date-fns';
import { uniqBy } from 'lodash';
import { format, isSameDay, subDays } from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms';
@Injectable()
@ -33,6 +36,7 @@ export class BenchmarkService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@ -202,8 +206,14 @@ export class BenchmarkService {
public async getMarketDataBySymbol({
dataSource,
startDate,
symbol
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
symbol,
userCurrency
}: {
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
@ -225,44 +235,101 @@ export class BenchmarkService {
})
]);
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
currencyFrom: currentSymbolItem.currency,
currencyTo: userCurrency,
dates: marketDataItems.map(({ date }) => {
return date;
})
});
const exchangeRateAtStartDate =
exchangeRates[format(startDate, DATE_FORMAT)];
if (!exchangeRateAtStartDate) {
Logger.error(
`No exchange rate has been found for ${
currentSymbolItem.currency
}${userCurrency} at ${format(startDate, DATE_FORMAT)}`,
'BenchmarkService'
);
return { marketData };
}
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
);
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
const response = {
marketData: [
...marketDataItems
.filter((marketDataItem, index) => {
return index % step === 0;
})
.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice
) * 100
};
})
]
};
let i = 0;
if (currentSymbolItem?.marketPrice) {
response.marketData.push({
for (let marketDataItem of marketDataItems) {
if (i % step !== 0) {
continue;
}
const exchangeRate =
exchangeRates[format(marketDataItem.date, DATE_FORMAT)];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesToday = isSameDay(
parseDate(last(marketData).date),
new Date()
);
if (currentSymbolItem?.marketPrice && !includesToday) {
const exchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(new Date(), DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return response;
return {
marketData
};
}
public async addBenchmark({
@ -339,7 +406,15 @@ export class BenchmarkService {
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
private getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent === 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
}
}

View File

@ -1,39 +1,18 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('cache')
export class CacheController {
public constructor(
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly redisCacheService: RedisCacheService) {}
@HasPermission(permissions.accessAdminControl)
@Post('flush')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.redisCacheService.reset();
}
}

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
Controller,
@ -19,7 +20,7 @@ export class ExchangeRateController {
) {}
@Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getExchangeRate(
@Param('dateString') dateString: string,
@Param('symbol') symbol: string

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
@ -14,12 +15,13 @@ export class ExportController {
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export(
@Query('activityIds') activityIds?: string[]
): Promise<Export> {
return this.exportService.export({
activityIds,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
}

View File

@ -13,9 +13,11 @@ export class ExportService {
public async export({
activityIds,
userCurrency,
userId
}: {
activityIds?: string[];
userCurrency: string;
userId: string;
}): Promise<Export> {
const accounts = (
@ -39,10 +41,13 @@ export class ExportService {
}
);
let activities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
let { activities } = await this.orderService.getOrders({
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
if (activityIds) {

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -34,7 +36,8 @@ export class ImportController {
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import(
@ -42,11 +45,7 @@ export class ImportController {
@Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> {
if (
!hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -92,7 +91,7 @@ export class ImportController {
}
@Get('dividends/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async gatherDividends(

View File

@ -26,7 +26,7 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
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 { v4 as uuidv4 } from 'uuid';
@ -83,12 +83,13 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => {
return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) &&
isSameSecond(activity.date, date) &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
@ -102,6 +103,7 @@ export class ImportService {
return {
Account,
date,
error,
quantity,
value,
@ -109,7 +111,6 @@ export class ImportService {
accountUserId: undefined,
comment: undefined,
createdAt: undefined,
date: parseDate(dateString),
fee: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
@ -235,6 +236,7 @@ export class ImportService {
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto,
userCurrency,
userId
});
@ -458,15 +460,18 @@ export class ImportService {
private async extendActivitiesWithErrors({
activitiesDto,
userCurrency,
userId
}: {
activitiesDto: Partial<CreateOrderDto>[];
userCurrency: string;
userId: string;
}): Promise<Partial<Activity>[]> {
const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
let { activities: existingActivities } = await this.orderService.getOrders({
userCurrency,
userId,
includeDrafts: true,
withExcludedAccounts: true
});
return activitiesDto.map(
@ -482,13 +487,13 @@ export class ImportService {
type,
unitPrice
}) => {
const date = parseISO(<string>(<unknown>dateString));
const date = parseISO(dateString);
const isDuplicate = existingActivities.some((activity) => {
return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&

View File

@ -8,7 +8,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
@ -162,7 +161,7 @@ export class InfoService {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
@ -187,7 +186,7 @@ export class InfoService {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore
@ -214,7 +213,7 @@ export class InfoService {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
@ -342,7 +341,7 @@ export class InfoService {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { data } = await got(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -9,6 +9,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class LogoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -46,7 +47,7 @@ export class LogoService {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,

View File

@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities {
activities: Activity[];
count: number;
}
export interface Activity extends OrderWithAccount {

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
@ -24,7 +26,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -44,24 +46,16 @@ export class OrderController {
) {}
@Delete()
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrders({
userId: this.request.user.id
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
@ -82,7 +76,7 @@ export class OrderController {
}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@ -90,6 +84,8 @@ export class OrderController {
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
@ -103,8 +99,10 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.orderService.getOrders({
const { activities, count } = await this.orderService.getOrders({
filters,
sortColumn,
sortDirection,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
@ -113,22 +111,14 @@ export class OrderController {
withExcludedAccounts: true
});
return { activities };
return { activities, count };
}
@HasPermission(permissions.createOrder)
@Post()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const order = await this.orderService.createOrder({
...data,
date: parseISO(data.date),
@ -166,19 +156,16 @@ export class OrderController {
return order;
}
@HasPermission(permissions.updateOrder)
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
const originalOrder = await this.orderService.order({
id
});
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN

View File

@ -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 { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';

View File

@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface';
import { Activities } from './interfaces/activities.interface';
@Injectable()
export class OrderService {
@ -37,34 +37,6 @@ export class OrderService {
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(
data: Prisma.OrderCreateInput & {
accountId?: string;
@ -231,6 +203,8 @@ export class OrderService {
filters,
includeDrafts = false,
skip,
sortColumn,
sortDirection,
take = Number.MAX_SAFE_INTEGER,
types,
userCurrency,
@ -240,12 +214,17 @@ export class OrderService {
filters?: Filter[];
includeDrafts?: boolean;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<Activity[]> {
}): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
const {
@ -307,6 +286,10 @@ export class OrderService {
};
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) {
where.OR = types.map((type) => {
return {
@ -317,8 +300,9 @@ export class OrderService {
});
}
return (
await this.orders({
const [orders, count] = await Promise.all([
this.orders({
orderBy,
skip,
take,
where,
@ -332,10 +316,12 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
tags: true
},
orderBy: { date: 'asc' }
})
)
}
}),
this.prismaService.order.count({ where })
]);
const activities = orders
.filter((order) => {
return (
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({
@ -439,4 +435,24 @@ export class OrderService {
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
});
}
}

View File

@ -1,18 +1,17 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,49 +22,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform')
export class PlatformController {
public constructor(
private readonly platformService: PlatformService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly platformService: PlatformService) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount();
}
@HasPermission(permissions.createPlatform)
@Post()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createPlatform(
@Body() data: CreatePlatformDto
): Promise<Platform> {
if (
!hasPermission(this.request.user.permissions, permissions.createPlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.platformService.createPlatform(data);
}
@HasPermission(permissions.updatePlatform)
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePlatform(
@Param('id') id: string,
@Body() data: UpdatePlatformDto
) {
if (
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalPlatform = await this.platformService.getPlatform({
id
});
@ -88,17 +68,9 @@ export class PlatformController {
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.deletePlatform)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deletePlatform(@Param('id') id: string) {
if (
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalPlatform = await this.platformService.getPlatform({
id
});

View File

@ -92,6 +92,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
timeWeightedInvestment: new Big('285.8'),
transactionCount: 2
}
],

View File

@ -81,6 +81,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
timeWeightedInvestment: new Big('273.2'),
transactionCount: 1
}
],

View File

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('13657.2'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
netPerformancePercentage: new Big('42.41978276196153750666'),
positions: [
{
averagePrice: new Big('320.43'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
investment: new Big('320.43'),
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
netPerformancePercentage: new Big('42.41978276196153750666'),
marketPrice: 13657.2,
quantity: new Big('1'),
symbol: 'BTCUSD',
timeWeightedInvestment: new Big('640.56763686131386861314'),
transactionCount: 2
}
],

View File

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentage: new Big('0.12184460284330327256'),
positions: [
{
averagePrice: new Big('75.80'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentage: new Big('0.12184460284330327256'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('145.10285714285714285714'),
transactionCount: 2
}
],

View File

@ -112,6 +112,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('151.6'),
transactionCount: 2
}
],

View File

@ -15,6 +15,7 @@ import {
addMilliseconds,
addMonths,
addYears,
differenceInDays,
endOfDay,
format,
isAfter,
@ -43,7 +44,7 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false;
@ -238,12 +239,13 @@ export class PortfolioCalculator {
}
}
const valuesByDate: {
const accumulatedValuesByDate: {
[date: string]: {
maxTotalInvestmentValue: Big;
totalCurrentValue: Big;
totalInvestmentValue: Big;
totalNetPerformanceValue: Big;
totalTimeWeightedInvestmentValue: Big;
};
} = {};
@ -253,6 +255,7 @@ export class PortfolioCalculator {
investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
};
} = {};
@ -261,7 +264,8 @@ export class PortfolioCalculator {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
netPerformanceValues,
timeWeightedInvestmentValues
} = this.getSymbolMetrics({
end,
marketSymbolMap,
@ -275,7 +279,8 @@ export class PortfolioCalculator {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
netPerformanceValues,
timeWeightedInvestmentValues
};
}
@ -293,38 +298,50 @@ export class PortfolioCalculator {
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
valuesByDate[dateString] = {
accumulatedValuesByDate[dateString] = {
totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValue),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ??
new Big(0)
).add(maxInvestmentValue),
totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue)
};
}
}
return Object.entries(valuesByDate).map(([date, values]) => {
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
totalNetPerformanceValue,
totalTimeWeightedInvestmentValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
let investmentValue =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
? totalTimeWeightedInvestmentValue
: maxTotalInvestmentValue;
const netPerformanceInPercentage = investmentValue.eq(0)
? 0
: totalNetPerformanceValue
.div(maxTotalInvestmentValue)
.mul(100)
.toNumber();
: totalNetPerformanceValue.div(investmentValue).mul(100).toNumber();
return {
date,
@ -447,7 +464,6 @@ export class PortfolioCalculator {
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
@ -461,9 +477,9 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
netPerformancePercentage,
timeWeightedInvestment
} = this.getSymbolMetrics({
end,
marketSymbolMap,
@ -472,9 +488,9 @@ export class PortfolioCalculator {
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({
timeWeightedInvestment,
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
@ -509,7 +525,7 @@ export class PortfolioCalculator {
}
}
const overall = this.calculateOverallPerformance(positions, initialValues);
const overall = this.calculateOverallPerformance(positions);
return {
...overall,
@ -732,18 +748,13 @@ export class PortfolioCalculator {
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [symbol: string]: Big }
) {
private calculateOverallPerformance(positions: TimelinePosition[]) {
let currentValue = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
@ -766,21 +777,9 @@ export class PortfolioCalculator {
hasErrors = true;
}
if (currentPosition.grossPerformancePercentage) {
// Use the average from the initial value and the current investment as
// a weight
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
.plus(currentPosition.investment)
.div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(weight)
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
@ -791,22 +790,18 @@ export class PortfolioCalculator {
}
}
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
totalInvestment,
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: netPerformance.div(totalTimeWeightedInvestment),
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: grossPerformance.div(totalTimeWeightedInvestment)
};
}
@ -1018,6 +1013,7 @@ export class PortfolioCalculator {
let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0);
const currentValues: { [date: string]: Big } = {};
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
@ -1025,12 +1021,12 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
@ -1122,6 +1118,9 @@ export class PortfolioCalculator {
return order.itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
@ -1162,11 +1161,11 @@ export class PortfolioCalculator {
order.type === 'BUY'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(this.getFactor(order.type))
: new Big(0);
? totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(this.getFactor(order.type))
: new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
@ -1174,6 +1173,7 @@ export class PortfolioCalculator {
console.log('transactionInvestment', transactionInvestment.toNumber());
}
const totalInvestmentBeforeTransaction = totalInvestment;
totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
@ -1243,14 +1243,51 @@ export class PortfolioCalculator {
grossPerformanceAtStartDate = grossPerformance;
}
if (isChartMode && i > indexOfStartOrder) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (valueOfInvestmentBeforeTransaction.gt(0)) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
// Set to at least 1 day, otherwise the transactions on the same day
// would not be considered in the time weighted calculation
if (daysSinceLastOrder <= 0) {
daysSinceLastOrder = 1;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
}
if (isChartMode) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
@ -1274,50 +1311,79 @@ export class PortfolioCalculator {
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
let grossPerformancePercentage: Big;
if (
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
) {
grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
.minus(1);
: new Big(0);
} else {
grossPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const netPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
let netPerformancePercentage: Big;
if (
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
) {
netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
.minus(1);
: new Big(0);
} else {
netPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
@ -1330,6 +1396,9 @@ export class PortfolioCalculator {
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
@ -1349,9 +1418,12 @@ export class PortfolioCalculator {
maxInvestmentValues,
netPerformancePercentage,
netPerformanceValues,
timeWeightedInvestmentValues,
grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate
};
}

View File

@ -1,5 +1,6 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
@ -61,7 +62,7 @@ export class PortfolioController {
) {}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@ -204,7 +205,7 @@ export class PortfolioController {
}
@Get('dividends')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@ -254,7 +255,7 @@ export class PortfolioController {
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@ -315,7 +316,7 @@ export class PortfolioController {
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
public async getPerformanceV2(
@ -346,16 +347,34 @@ export class PortfolioController {
this.userService.isRestrictedView(this.request.user)
) {
performanceInformation.chart = performanceInformation.chart.map(
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
({
date,
netPerformanceInPercentage,
netWorth,
totalInvestment,
value
}) => {
return {
date,
netPerformanceInPercentage,
totalInvestment: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0
? 0
: new Big(netWorth)
.div(performanceInformation.performance.currentNetWorth)
.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 +384,7 @@ export class PortfolioController {
[
'currentGrossPerformance',
'currentNetPerformance',
'currentNetWorth',
'currentValue',
'totalInvestment'
]
@ -386,7 +406,7 @@ export class PortfolioController {
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@ -481,7 +501,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@ -504,7 +524,7 @@ export class PortfolioController {
}
@Get('report')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> {

View File

@ -1,8 +1,8 @@
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 { OrderModule } from '@ghostfolio/api/app/order/order.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';

View File

@ -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 { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -67,14 +68,16 @@ import {
isBefore,
isSameMonth,
isSameYear,
isValid,
max,
min,
parseISO,
set,
setDayOfYear,
subDays,
subYears
} from 'date-fns';
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
import {
HistoricalDataContainer,
@ -91,6 +94,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
@ -114,8 +118,12 @@ export class PortfolioService {
}): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId };
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') {
where.id = filters[0].id;
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
if (accountFilter) {
where.id = accountFilter.id;
}
const [accounts, details] = await Promise.all([
@ -217,7 +225,7 @@ export class PortfolioService {
}): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({
const { activities } = await this.orderService.getOrders({
filters,
userId,
types: ['DIVIDEND'],
@ -267,6 +275,13 @@ export class PortfolioService {
includeDrafts: true
});
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
@ -274,12 +289,6 @@ export class PortfolioService {
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
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({
dateRange = 'max',
filters,
@ -731,13 +679,13 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const orders = (
await this.orderService.getOrders({
userCurrency,
userId,
withExcludedAccounts: true
})
).filter(({ SymbolProfile }) => {
const { activities } = await this.orderService.getOrders({
userCurrency,
userId,
withExcludedAccounts: true
});
const orders = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
@ -879,7 +827,7 @@ export class PortfolioService {
let currentAveragePrice = 0;
let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find(
const currentSymbol = transactionPoints[j]?.items.find(
({ symbol }) => {
return symbol === aSymbol;
}
@ -1028,12 +976,6 @@ export class PortfolioService {
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
return {
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);
const portfolioStart = parseDate(transactionPoints[0].date);
@ -1087,25 +1035,44 @@ export class PortfolioService {
return {
hasErrors: currentPositions.hasErrors,
positions: positions.map((position) => {
return {
...position,
assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
marketState:
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
position.netPerformancePercentage?.toNumber() ?? null,
quantity: new Big(position.quantity).toNumber()
};
})
positions: positions.map(
({
averagePrice,
currency,
dataSource,
firstBuyDate,
investment,
grossPerformance,
grossPerformancePercentage,
netPerformance,
netPerformancePercentage,
quantity,
symbol,
transactionCount
}) => {
return {
currency,
dataSource,
firstBuyDate,
symbol,
transactionCount,
assetClass: symbolProfileMap[symbol].assetClass,
assetSubClass: symbolProfileMap[symbol].assetSubClass,
averagePrice: averagePrice.toNumber(),
grossPerformance: grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
grossPerformancePercentage?.toNumber() ?? null,
investment: investment.toNumber(),
marketState:
dataProviderResponses[symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[symbol].name,
netPerformance: netPerformance?.toNumber() ?? null,
netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null,
quantity: quantity.toNumber()
};
}
)
};
}
@ -1126,6 +1093,31 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
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 } =
await this.getTransactionPoints({
filters,
@ -1139,7 +1131,7 @@ export class PortfolioService {
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) {
return {
chart: [],
firstOrderDate: undefined,
@ -1149,6 +1141,7 @@ export class PortfolioService {
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetWorth: 0,
currentValue: 0,
totalInvestment: 0
}
@ -1157,7 +1150,15 @@ export class PortfolioService {
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 {
currentValue,
@ -1175,17 +1176,17 @@ export class PortfolioService {
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
const historicalDataContainer = await this.getChart({
const { items } = await this.getChart({
dateRange,
filters,
impersonationId,
portfolioOrders,
transactionPoints,
userCurrency,
userId,
withExcludedAccounts
userId
});
const itemOfToday = historicalDataContainer.items.find((item) => {
return item.date === format(new Date(), DATE_FORMAT);
const itemOfToday = items.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (itemOfToday) {
@ -1195,34 +1196,42 @@ export class PortfolioService {
).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 {
errors,
hasErrors,
chart: historicalDataContainer.items.map(
({
date,
netPerformance: netPerformanceOfItem,
netPerformanceInPercentage,
totalInvestment: totalInvestmentOfItem,
value
}) => {
return {
date,
netPerformanceInPercentage,
value,
netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
};
}
),
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
chart: mergedHistoricalDataItems,
firstOrderDate: parseDate(items[0]?.date),
performance: {
currentValue: currentValue.toNumber(),
currentNetWorth,
currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentValue: currentValue.toNumber(),
totalInvestment: totalInvestment.toNumber()
}
};
@ -1376,6 +1385,62 @@ export class PortfolioService {
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({
dividends,
groupBy
@ -1593,18 +1658,18 @@ export class PortfolioService {
userId
});
const activities = await this.orderService.getOrders({
const { activities } = await this.orderService.getOrders({
userCurrency,
userId
});
const excludedActivities = (
await this.orderService.getOrders({
userCurrency,
userId,
withExcludedAccounts: true
})
).filter(({ Account: account }) => {
let { activities: excludedActivities } = await this.orderService.getOrders({
userCurrency,
userId,
withExcludedAccounts: true
});
excludedActivities = excludedActivities.filter(({ Account: account }) => {
return account?.isExcluded ?? false;
});
@ -1784,7 +1849,7 @@ export class PortfolioService {
const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({
const { activities, count } = await this.orderService.getOrders({
filters,
includeDrafts,
userCurrency,
@ -1793,11 +1858,11 @@ export class PortfolioService {
types: ['BUY', 'SELL']
});
if (orders.length <= 0) {
if (count <= 0) {
return { transactionPoints: [], orders: [], portfolioOrders: [] };
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
@ -1831,8 +1896,8 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints();
return {
orders,
portfolioOrders,
orders: activities,
transactionPoints: portfolioCalculator.getTransactionPoints()
};
}
@ -1867,13 +1932,14 @@ export class PortfolioService {
userId: string;
withExcludedAccounts?: boolean;
}) {
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM', 'LIABILITY']
});
const { activities: ordersOfTypeItemOrLiability } =
await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM', 'LIABILITY']
});
const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {};
@ -1999,4 +2065,44 @@ export class PortfolioService {
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;
}
}

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
@ -37,7 +38,7 @@ export class SubscriptionController {
@Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) {
throw new HttpException(
@ -109,7 +110,7 @@ export class SubscriptionController {
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {

View File

@ -111,14 +111,14 @@ export class SubscriptionService {
aSubscriptions: Subscription[]
): UserWithSettings['subscription'] {
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 {
expiresAt: latestSubscription.expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
type: isBefore(new Date(), latestSubscription.expiresAt)
expiresAt,
offer: price ? 'renewal' : 'default',
type: isBefore(new Date(), expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
@ -34,7 +35,7 @@ export class SymbolController {
* Must be before /:symbol
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query('includeIndices') includeIndices: boolean = false,
@ -88,7 +89,7 @@ export class SymbolController {
}
@Get(':dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,

View File

@ -1,18 +1,17 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,40 +22,25 @@ import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
public constructor(private readonly tagService: TagService) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.createTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
@ -79,15 +63,9 @@ export class TagController {
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,12 +38,10 @@ export class UserController {
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.deleteUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
id === this.request.user.id
) {
if (id === this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -54,7 +54,7 @@ export class UserController {
}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
@ -92,7 +92,7 @@ export class UserController {
}
@Put('setting')
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
size(data) === 1 &&

View File

@ -60,7 +60,7 @@ export class UserService {
PROPERTY_SYSTEM_MESSAGE
)) as SystemMessage;
if (systemMessageProperty?.targetGroups?.includes(subscription.type)) {
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
systemMessage = systemMessageProperty;
}
@ -198,16 +198,18 @@ export class UserService {
new Date(),
user.createdAt
);
let frequency = 20;
let frequency = 15;
if (daysSinceRegistration > 180) {
if (daysSinceRegistration > 365) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
frequency = 5;
} else if (daysSinceRegistration > 30) {
frequency = 10;
frequency = 8;
} else if (daysSinceRegistration > 15) {
frequency = 15;
frequency = 12;
}
if (Analytics?.activityCount % frequency === 1) {

View File

@ -82,10 +82,18 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -94,6 +102,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -202,6 +214,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -214,6 +230,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -326,6 +346,10 @@
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
@ -392,10 +416,18 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -404,6 +436,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -512,6 +548,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -524,6 +564,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -726,10 +770,18 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -738,6 +790,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -846,6 +902,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -858,6 +918,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -906,10 +970,18 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -918,6 +990,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1026,6 +1102,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1038,6 +1118,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View 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);
}

View File

@ -0,0 +1,50 @@
import { HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { HasPermissionGuard } from './has-permission.guard';
describe('HasPermissionGuard', () => {
let guard: HasPermissionGuard;
let reflector: Reflector;
beforeEach(async () => {
reflector = new Reflector();
guard = new HasPermissionGuard(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);
});
});

View 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 { user } = context.switchToHttp().getRequest();
const requiredPermission = this.reflector.get<string>(
HAS_PERMISSION_KEY,
context.getHandler()
);
if (!requiredPermission) {
// No specific permissions required
return true;
}
if (!user || !hasPermission(user.permissions, requiredPermission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

View File

@ -32,9 +32,11 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
}
export function redactAttributes({
isFirstRun = true,
object,
options
}: {
isFirstRun?: boolean;
object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any {
@ -42,7 +44,10 @@ export function redactAttributes({
return object;
}
const redactedObject = cloneDeep(object);
// Create deep clone
const redactedObject = isFirstRun
? JSON.parse(JSON.stringify(object))
: object;
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
@ -59,7 +64,11 @@ export function redactAttributes({
if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({ options, object: currentObject });
return redactAttributes({
options,
isFirstRun: false,
object: currentObject
});
}
);
} else if (
@ -69,6 +78,7 @@ export function redactAttributes({
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,
isFirstRun: false,
object: redactedObject[property]
});
}

View File

@ -76,6 +76,10 @@ const locales = {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
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}`
@ -87,6 +91,9 @@ const isFileRequest = (filename: string) => {
return true;
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -44,6 +44,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }),
ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),

View File

@ -5,7 +5,6 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -107,7 +106,7 @@ export class AlphaVantageService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;

View File

@ -1,13 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
@ -25,7 +23,9 @@ import got from 'got';
export class CoinGeckoService implements DataProviderInterface {
private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor() {}
public constructor(
private readonly configurationService: ConfigurationService
) {}
public canHandle(symbol: string) {
return true;
@ -47,7 +47,7 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
// @ts-ignore
@ -56,7 +56,15 @@ export class CoinGeckoService implements DataProviderInterface {
response.name = name;
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'CoinGeckoService');
}
return response;
@ -89,7 +97,7 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { prices } = await got(
`${
@ -135,7 +143,7 @@ export class CoinGeckoService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;
@ -174,7 +182,15 @@ export class CoinGeckoService implements DataProviderInterface {
};
}
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'CoinGeckoService');
}
return response;
@ -198,7 +214,7 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { coins } = await got(`${this.URL}/search?query=${query}`, {
// @ts-ignore
@ -216,7 +232,15 @@ export class CoinGeckoService implements DataProviderInterface {
};
});
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'CoinGeckoService');
}
return { items };

View File

@ -1,6 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
@ -15,7 +14,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
) {}
public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response,
symbol
}: {

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
@ -13,6 +13,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = {
'Russian Federation': 'Russia'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples',
@ -20,8 +21,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
'Information Technology': 'Technology'
};
public constructor(
private readonly configurationService: ConfigurationService
) {}
public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response,
symbol
}: {
@ -54,7 +59,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
@ -81,7 +86,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const holdings = await got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
@ -96,7 +101,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
@ -113,7 +118,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
});
});
if (holdings?.weight < 0.95) {
if (
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold
) {
// Skip if data is inaccurate
return response;
}

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -25,13 +26,16 @@ jest.mock(
);
describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService
);
});

View File

@ -1,10 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -22,6 +19,7 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {}
@ -76,7 +74,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
}
public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response,
symbol
}: {

View File

@ -74,6 +74,6 @@ import { DataProviderService } from './data-provider.service';
},
YahooFinanceDataEnhancerService
],
exports: [DataProviderService, YahooFinanceService]
exports: [DataProviderService, ManualService, YahooFinanceService]
})
export class DataProviderModule {}

View File

@ -346,7 +346,7 @@ export class DataProviderService {
);
try {
this.marketDataService.updateMany({
await this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
return (

View File

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -82,7 +79,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const response = await got(
`${this.URL}/eod/${symbol}?api_token=${
@ -132,7 +129,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;
@ -194,7 +191,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency;
result[this.convertFromEodSymbol(code)] = {
currency: currency ?? DEFAULT_CURRENCY,
currency:
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -208,7 +207,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${DEFAULT_CURRENCY}GBp`,
currency: 'GBp',
marketPrice: this.getConvertedValue({
symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
@ -219,7 +218,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${DEFAULT_CURRENCY}ILA`,
currency: 'ILA',
marketPrice: this.getConvertedValue({
symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
@ -227,9 +226,26 @@ export class EodHistoricalDataService implements DataProviderInterface {
};
}
if (response[`${DEFAULT_CURRENCY}USX`]) {
response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response;
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
}
return {};
@ -353,7 +369,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
@ -382,7 +398,15 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
);
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
}
return searchResult;

View File

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
@ -70,7 +67,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
@ -114,7 +111,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;
@ -151,7 +148,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
};
}
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
}
return response;
@ -175,7 +180,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
@ -196,7 +201,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
};
});
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
}
return { items };

View File

@ -7,7 +7,6 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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 { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -101,7 +100,7 @@ export class GoogleSheetsService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;

View File

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
@ -6,23 +7,25 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { ScraperConfiguration } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns';
import got from 'got';
import got, { Headers } from 'got';
import jsonpath from 'jsonpath';
@Injectable()
export class ManualService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -96,21 +99,7 @@ export class ManualService implements DataProviderInterface {
return {};
}
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got(url, {
headers,
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);
const value = extractNumberFromString($(selector).text());
const value = await this.scrape(symbolProfile.scraperConfiguration);
return {
[symbol]: {
@ -134,7 +123,7 @@ export class ManualService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;
@ -232,4 +221,43 @@ export class ManualService implements DataProviderInterface {
return { items };
}
public async test(scraperConfiguration: ScraperConfiguration) {
return this.scrape(scraperConfiguration);
}
private async scrape(
scraperConfiguration: ScraperConfiguration
): Promise<number> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { body, headers } = await got(scraperConfiguration.url, {
headers: scraperConfiguration.headers as Headers,
// @ts-ignore
signal: abortController.signal
});
if (headers['content-type'] === 'application/json') {
const data = JSON.parse(body);
const value = String(
jsonpath.query(data, scraperConfiguration.selector)[0]
);
return extractNumberFromString(value);
} else {
const $ = cheerio.load(body);
return extractNumberFromString(
$(scraperConfiguration.selector).first().text()
);
}
} catch (error) {
throw error;
}
}
}

View File

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_REQUEST_TIMEOUT,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -88,7 +85,7 @@ export class RapidApiService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;
@ -146,7 +143,7 @@ export class RapidApiService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
@ -163,7 +160,15 @@ export class RapidApiService implements DataProviderInterface {
return fgi;
} 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 ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'RapidApiService');
return undefined;
}

View File

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -6,10 +7,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -22,6 +20,7 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) {}
@ -160,7 +159,7 @@ export class YahooFinanceService implements DataProviderInterface {
}
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: {
requestTimeout?: number;

View File

@ -11,6 +11,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format, isToday } from 'date-fns';
import { isNumber, uniq } from 'lodash';
import ms from 'ms';
@Injectable()
export class ExchangeRateDataService {
@ -33,6 +34,125 @@ export class ExchangeRateDataService {
return this.currencyPairs;
}
public async getExchangeRates({
currencyFrom,
currencyTo,
dates
}: {
currencyFrom: string;
currencyTo: string;
dates: Date[];
}) {
let factors: { [dateString: string]: number } = {};
if (currencyFrom === currencyTo) {
for (const date of dates) {
factors[format(date, DATE_FORMAT)] = 1;
}
} else {
const dataSource =
this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${currencyFrom}${currencyTo}`;
const marketData = await this.marketDataService.getRange({
dateQuery: { in: dates },
uniqueAssets: [
{
dataSource,
symbol
}
]
});
if (marketData?.length > 0) {
for (const { date, marketPrice } of marketData) {
factors[format(date, DATE_FORMAT)] = marketPrice;
}
} else {
// Calculate indirectly via base currency
let marketPriceBaseCurrencyFromCurrency: {
[dateString: string]: number;
} = {};
let marketPriceBaseCurrencyToCurrency: {
[dateString: string]: number;
} = {};
try {
if (currencyFrom === DEFAULT_CURRENCY) {
for (const date of dates) {
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
1;
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: { in: dates },
uniqueAssets: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
}
]
});
for (const { date, marketPrice } of marketData) {
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
marketPrice;
}
}
} catch {}
try {
if (currencyTo === DEFAULT_CURRENCY) {
for (const date of dates) {
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1;
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: {
in: dates
},
uniqueAssets: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
}
]
});
for (const { date, marketPrice } of marketData) {
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] =
marketPrice;
}
}
} catch {}
for (const date of dates) {
try {
const factor =
(1 /
marketPriceBaseCurrencyFromCurrency[
format(date, DATE_FORMAT)
]) *
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
factors[format(date, DATE_FORMAT)] = factor;
} catch {
Logger.error(
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
date,
DATE_FORMAT
)}`,
'ExchangeRateDataService'
);
}
}
}
}
return factors;
}
public hasCurrencyPair(currency1: string, currency2: string) {
return this.currencyPairs.some(({ symbol }) => {
return (
@ -75,7 +195,8 @@ export class ExchangeRateDataService {
const quotes = await this.dataProviderService.getQuotes({
items: this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
}),
requestTimeout: ms('30 seconds')
});
for (const symbol of Object.keys(quotes)) {

View File

@ -32,6 +32,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string;
REDIS_PASSWORD: string;
REDIS_PORT: number;
REQUEST_TIMEOUT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;

View File

@ -64,7 +64,7 @@ export class MarketDataService {
dateQuery: DateQuery;
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> {
return await this.prismaService.marketData.findMany({
return this.prismaService.marketData.findMany({
orderBy: [
{
date: 'asc'
@ -74,17 +74,17 @@ export class MarketDataService {
}
],
where: {
OR: uniqueAssets.map(({ dataSource, symbol }) => {
return {
AND: [
{
dataSource,
symbol,
date: dateQuery
}
]
};
})
dataSource: {
in: uniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
date: dateQuery,
symbol: {
in: uniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
});
}

View File

@ -89,6 +89,7 @@ export class SymbolProfileService {
assetClass,
assetSubClass,
comment,
currency,
dataSource,
name,
scraperConfiguration,
@ -100,6 +101,7 @@ export class SymbolProfileService {
assetClass,
assetSubClass,
comment,
currency,
name,
scraperConfiguration,
symbolMapping

View File

@ -0,0 +1,6 @@
const { composePlugins, withNx } = require('@nx/webpack');
module.exports = composePlugins(withNx(), (config, { options, context }) => {
// Customize webpack config here
return config;
});

View File

@ -150,41 +150,41 @@
}
},
"serve": {
"executor": "@nx/angular:webpack-dev-server",
"executor": "@nx/angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
"proxyConfig": "apps/client/proxy.conf.json",
"buildTarget": "client:build"
},
"configurations": {
"development-de": {
"browserTarget": "client:build:development-de"
"buildTarget": "client:build:development-de"
},
"development-en": {
"browserTarget": "client:build:development-en"
"buildTarget": "client:build:development-en"
},
"development-es": {
"browserTarget": "client:build:development-es"
"buildTarget": "client:build:development-es"
},
"development-fr": {
"browserTarget": "client:build:development-fr"
"buildTarget": "client:build:development-fr"
},
"development-it": {
"browserTarget": "client:build:development-it"
"buildTarget": "client:build:development-it"
},
"development-nl": {
"browserTarget": "client:build:development-nl"
"buildTarget": "client:build:development-nl"
},
"development-pl": {
"browserTarget": "client:build:development-pl"
"buildTarget": "client:build:development-pl"
},
"development-pt": {
"browserTarget": "client:build:development-pt"
"buildTarget": "client:build:development-pt"
},
"development-tr": {
"browserTarget": "client:build:development-tr"
"buildTarget": "client:build:development-tr"
},
"production": {
"browserTarget": "client:build:production"
"buildTarget": "client:build:production"
}
}
},
@ -207,7 +207,7 @@
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
@ -215,8 +215,7 @@
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
"jestConfig": "apps/client/jest.config.ts"
},
"outputs": ["{workspaceRoot}/coverage/apps/client"]
}

View File

@ -1,4 +1,3 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { getDateFormatString } from '@ghostfolio/common/helper';
@ -7,10 +6,9 @@ import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter {
public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
) {
super(matDateLocale, platform);
super(matDateLocale);
}
/**

View File

@ -2,7 +2,7 @@
:host {
display: block;
min-height: 100vh;
min-height: 100svh;
&.has-info-message {
header {
@ -30,7 +30,7 @@
}
main {
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height));
min-height: calc(100svh - 2 * var(--mat-toolbar-standard-height));
}
}
@ -44,7 +44,7 @@
}
main {
min-height: calc(100vh - var(--mat-toolbar-standard-height));
min-height: calc(100svh - var(--mat-toolbar-standard-height));
}
}

View File

@ -14,30 +14,30 @@
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted View
</ng-container>
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted View</ng-container>
</div>
</td>
</ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'">
<div *ngIf="element.type === 'PUBLIC'" class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline"></ion-icon>
<a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
>
</ng-container>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>

View File

@ -7,11 +7,18 @@ import {
OnInit
} from '@angular/core';
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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
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 Big from 'big.js';
import { format, parseISO } from 'date-fns';
@ -29,15 +36,22 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public balance: number;
public currency: string;
public dataSource: MatTableDataSource<OrderWithAccount>;
public equity: number;
public hasImpersonationId: boolean;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingActivities: boolean;
public isLoadingChart: boolean;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public totalItems: number;
public transactionCount: number;
public user: User;
public valueInBaseCurrency: number;
@ -58,14 +72,17 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToDeleteAccountBalance = hasPermission(
this.user.permissions,
permissions.deleteAccountBalance
);
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {
this.isLoadingChart = true;
this.dataService
.fetchAccount(this.data.accountId)
.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
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioPerformance();
}
public onClose() {
this.dialogRef.close();
}
public onExport() {
public onDeleteAccountBalance(aId: string) {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.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))
.subscribe((data) => {
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() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -31,7 +31,7 @@
></gf-investment-chart>
</div>
<div class="row">
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value
i18n
@ -64,11 +64,33 @@
</div>
</div>
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<mat-tab-group
animationDuration="0"
[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
[activities]="orders"
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
@ -79,8 +101,18 @@
[showActions]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
</mat-tab>
<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>

View File

@ -2,9 +2,12 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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 { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -15,13 +18,16 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfAccountBalancesModule,
GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfInvestmentChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -241,7 +241,7 @@
></td>
</ng-container>
<ng-container matColumnDef="actions">
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button

View File

@ -120,7 +120,7 @@
</td>
</ng-container>
<ng-container matColumnDef="actions">
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button
class="mx-1 no-min-width px-2"

View File

@ -9,7 +9,7 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
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 { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service';
@ -19,7 +19,7 @@ import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
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 { Subject } from 'rxjs';
@ -160,7 +160,7 @@ export class AdminMarketDataComponent
this.loadData({
sortColumn,
sortDirection: <Prisma.SortOrder>direction,
sortDirection: direction,
pageIndex: this.paginator.pageIndex
});
}
@ -175,7 +175,7 @@ export class AdminMarketDataComponent
this.loadData({
pageIndex: page.pageIndex,
sortColumn: this.sort.active,
sortDirection: <Prisma.SortOrder>this.sort.direction
sortDirection: this.sort.direction
});
}
@ -262,7 +262,7 @@ export class AdminMarketDataComponent
}: {
pageIndex: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
sortDirection?: SortDirection;
} = { pageIndex: 0 }
) {
this.isLoading = true;

View File

@ -129,7 +129,7 @@
</td>
</ng-container>
<ng-container matColumnDef="actions">
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"

View File

@ -15,6 +15,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
Currency,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -51,6 +52,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
comment: '',
currency: '',
historicalData: this.formBuilder.group({
csvString: ''
}),
@ -63,6 +65,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public currencies: Currency[] = [];
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public sectors: {
@ -86,7 +89,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
) {}
public ngOnInit(): void {
this.benchmarks = this.dataService.fetchInfo().benchmarks;
const { benchmarks, currencies } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
this.currencies = currencies.map((currency) => ({
label: currency,
value: currency
}));
this.initialize();
}
@ -132,6 +141,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '',
currency: this.assetProfile?.currency,
historicalData: {
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
},
@ -245,12 +255,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {}
const assetProfileData: UpdateAssetProfileDto = {
scraperConfiguration,
symbolMapping,
assetClass: this.assetProfileForm.controls['assetClass'].value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value,
scraperConfiguration,
symbolMapping
currency: (<Currency>(
(<unknown>this.assetProfileForm.controls['currency'].value)
))?.value,
name: this.assetProfileForm.controls['name'].value
};
this.adminService
@ -264,6 +277,34 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onTestMarketData() {
this.adminService
.testMarketData({
dataSource: this.data.dataSource,
scraperConfiguration:
this.assetProfileForm.controls['scraperConfiguration'].value,
symbol: this.data.symbol
})
.pipe(
catchError(({ error }) => {
alert(`Error: ${error?.message}`);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ price }) => {
alert(
$localize`The current market price is` +
' ' +
price +
' ' +
(<Currency>(
(<unknown>this.assetProfileForm.controls['currency'].value)
))?.value
);
});
}
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService
.deleteBenchmark({ dataSource, symbol })

View File

@ -183,6 +183,15 @@
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
@ -234,12 +243,24 @@
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Scraper Configuration</mat-label>
<textarea
cdkTextareaAutosize
formControlName="scraperConfiguration"
matInput
type="text"
></textarea>
<div class="align-items-end d-flex">
<textarea
cdkTextareaAutosize
formControlName="scraperConfiguration"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
<button
color="accent"
mat-flat-button
type="button"
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</mat-form-field>
</div>
<div>

View File

@ -10,6 +10,7 @@ import { MatMenuModule } from '@angular/material/menu';
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 { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
CommonModule,
FormsModule,
GfAdminMarketDataDetailModule,
GfCurrencySelectorModule,
GfPortfolioProportionChartModule,
GfValueModule,
MatButtonModule,

View File

@ -119,8 +119,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
const currency = prompt($localize`Please add a currency:`);
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
if (currency.length === 3) {
const currencies = uniq([...this.customCurrencies, currency]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else {
alert($localize`${currency} is an invalid currency!`);
}
}
}

View File

@ -68,7 +68,7 @@
</td>
</ng-container>
<ng-container matColumnDef="actions">
<ng-container matColumnDef="actions" stickyEnd>
<th
*matHeaderCellDef
class="px-1 text-center"

View File

@ -48,7 +48,7 @@
</td>
</ng-container>
<ng-container matColumnDef="actions">
<ng-container matColumnDef="actions" stickyEnd>
<th
*matHeaderCellDef
class="px-1 text-center"

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